Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit b960db2e8bf87b96c14f6421402a16798d7a9ded


Parents : 4ac2701
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-24T05:40:54-05:00

feat: integrate HTTPInterface for RNS-over-HTTP support, improve build metadata handling, and update dependencies

Changes

68 files changed, 7622 insertions(+), 261 deletions(-)

M CHANGELOG.md +9 -5
M uv.lock +97

Diff

diff --git a/.github/workflows/android-apk-tag.yml b/.github/workflows/android-apk-tag.yml
index f64be54c..1a85b707 100644
--- a/.github/workflows/android-apk-tag.yml
+++ b/.github/workflows/android-apk-tag.yml
@@ -183,6 +183,13 @@ jobs:
"chaquopy_libcodec2-*-android_24_armeabi_v7a.whl"
"lxst-*-py3-none-any.whl"
"bleak-*-py3-none-any.whl"
+ "httpx-0.28.1-py3-none-any.whl"
+ "httpcore-*-py3-none-any.whl"
+ "h2-*-py3-none-any.whl"
+ "h11-*-py3-none-any.whl"
+ "anyio-*-py3-none-any.whl"
+ "hpack-*-py3-none-any.whl"
+ "hyperframe-*-py3-none-any.whl"
)
missing=0
for pattern in "${required[@]}"; do

diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml
index 01b37c3f..f959eff0 100644
--- a/.github/workflows/android-build.yml
+++ b/.github/workflows/android-build.yml
@@ -211,6 +211,13 @@ jobs:
"chaquopy_libcodec2-*-android_24_armeabi_v7a.whl"
"lxst-*-py3-none-any.whl"
"bleak-*-py3-none-any.whl"
+ "httpx-0.28.1-py3-none-any.whl"
+ "httpcore-*-py3-none-any.whl"
+ "h2-*-py3-none-any.whl"
+ "h11-*-py3-none-any.whl"
+ "anyio-*-py3-none-any.whl"
+ "hpack-*-py3-none-any.whl"
+ "hyperframe-*-py3-none-any.whl"
)
missing=0
for pattern in "${required[@]}"; do

diff --git a/.gitignore b/.gitignore
index c84250f5..188d0999 100644
--- a/.gitignore
+++ b/.gitignore
@@ -177,3 +177,6 @@ scripts/private/
*.rsm
*.rfe
!/meshchatx.rsm
+
+# Generated at build time (scripts/bake_build_meta.js)
+meshchatx/src/_build_meta_baked.py

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1987bfbe..745e845f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -28,17 +28,21 @@ All notable changes to this project will be documented in this file.
- Relay Chat room keys so hosts can require a key to join a room
- Desktop privacy: Windows screen security to omit MeshChatX from screenshots, recording, and Recall
- Android privacy options to block screenshots and clear the clipboard when backgrounded
-- Tutorial connect: **Internet + local (recommended)** adds AutoInterface and pre-selects 3 random community TCP bootstraps
+- Builds bake git commit and channel into runtime metadata for About (commit) and sidebar (dev label plus short SHA on nightly, preview, and local builds)
+- Tutorial connect: Internet + local (recommended) adds AutoInterface and pre-selects 3 random community TCP bootstraps
- Remote management allow-list for identities that may query this instance with rnstatus/rnpath
- Post-install prompts for existing users after upgrades
- Coolify-oriented Docker Compose with resource limits for deployments
- LXST telephony half-duplex mode, live duplex switching, push-to-talk (packetizer squelch), and richer in-call stats (rates plus mute state on the active call)
-- Optional Linux seccomp-BPF syscall denylist (libseccomp) alongside Landlock, with auto-detect and `MESHCHAT_SECCOMP` fallback
+- Optional Linux seccomp-BPF syscall denylist (libseccomp) alongside Landlock, with auto-detect and MESHCHAT_SECCOMP fallback
+- Bundled [RNS-over-HTTP](https://github.com/Quad4-Software/RNS-over-HTTP) HTTPInterface with Interfaces page client/server setup, auto-install into the Reticulum interface path, and httpx support (Android includes httpx with HTTP/2 as pure-Python wheels)
### Changed
+- Tutorial: language/theme controls no longer overlay bootstrap titles. Connection and bootstrap actions stay locked while discovery or random pick is running
+- Tutorial connect/bootstrap: recommended mode stays on the bootstrap step for next/back, Finish is only on the last step, and random-pick races no longer stick busy or mark auto-pick done without a selection
- Tutorial privacy/security step: shorter mobile copy and toggle layout so labels and switches fit
-- Tutorial nav copy uses ASCII `->` instead of Unicode arrows
+- Auto propagation finder: require usable RNS paths and a scarce LXMF sync probe (treat **PR_COMPLETE** as success), remember verified destination hashes per identity, prefer live announces over memory ghosts, cool down failures, and avoid disruptive re-probes of a working preferred peer
- Community interface presets refreshed from directory.rns.recipes (69 online listings)
- Bundled Reticulum manual refetch (reticulum_docs_bundle.json timestamp)
- Discovery map markers use dual-halo badge icons (peers, LXMF, discovered, tracking, stale) with banded cluster badges, zoom-gated labels, and cached styles for denser maps
@@ -61,7 +65,7 @@ All notable changes to this project will be documented in this file.
### Fixed
-- Desktop AppImage: main-process logs always append to storage `logs/meshchatx.log`. Stdout is only used when a TTY is attached, with broken-pipe guards as a fallback, so background launches no longer raise **write EPIPE** dialogs
+- Desktop AppImage: main-process logs always append to the storage logs folder (meshchatx.log). Stdout is only used when a terminal is attached, with broken-pipe guards as a fallback, so background launches no longer raise write EPIPE dialogs
- Android: lxmfy packaging, flock soft-lock, splash/logo clipping, Landlock skipped on Android
- Android RNode BLE/USB via Chaquopy
- Startup check and disable unsupported interfaces
@@ -83,7 +87,7 @@ All notable changes to this project will be documented in this file.
- Unread badges dismiss when navigating back to an already-open Messages or Relay Chat room
- Startup stage logs no longer print the same stage twice
- Ctrl+C shutdown no longer floods reentrant logging errors from RNS.exit containment
-- RN Status interface mode labels match Reticulum modes again, including Internal
+- RNStatus interface mode labels match Reticulum modes again, including Internal
## [4.7.2] - 2026-07-06

diff --git a/android/README.md b/android/README.md
index 9ccb5250..5762dd53 100644
--- a/android/README.md
+++ b/android/README.md
@@ -6,7 +6,7 @@ Native APK with embedded Python (`meshchatx/`) and a WebView UI.
- Android SDK (`ANDROID_HOME` / `ANDROID_SDK_ROOT`) with `cmdline-tools` and a matching **NDK** (see `android/app/build.gradle` for the pinned NDK version used in CI).
- **JDK 17** (Temurin or compatible).
-- Chaquopy vendor wheels under `android/vendor/` (build locally with `bash scripts/build-android-wheels-local.sh` from repo root, or use CI artifacts).
+- Chaquopy vendor wheels under `android/vendor/` (build locally with `bash scripts/build-android-wheels-local.sh` from repo root, or use CI artifacts). That script builds native recipes under `android/chaquopy-recipes/` and also vendors pure-Python wheels such as bleak and `httpx[http2]` (for bundled RNS-over-HTTP / `HTTPInterface`).
## Lint and static analysis

diff --git a/android/app/build.gradle b/android/app/build.gradle
index f4b0f306..00a736b9 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -129,7 +129,24 @@ tasks.register("verifyVendorWheels") {
"cbor2": "5.6.5",
"cryptography": "49.0.0",
]
+ def purePythonVendorPrefixes = [
+ "httpx-0.28.1-",
+ "httpcore-",
+ "h2-",
+ "h11-",
+ "anyio-",
+ "hpack-",
+ "hyperframe-",
+ ]
def wheels = vendorWheelDir.list()?.toList() ?: []
+ purePythonVendorPrefixes.each { prefix ->
+ if (!wheels.any { it.startsWith(prefix) && it.endsWith(".whl") }) {
+ throw new org.gradle.api.GradleException(
+ "Missing ${prefix}*.whl in ${vendorWheelDir} (httpx/RNS-over-HTTP). " +
+ "Run: bash scripts/build-android-wheels-local.sh"
+ )
+ }
+ }
selectedAndroidAbis.each { abi ->
def abiTag = abi.replace("-", "_")
vendorPackages.each { pkg, ver ->
@@ -364,6 +381,9 @@ chaquopy {
install "usbserial4a==0.4.0"
install "ply>=3.11,<4.0"
install "cbor2==5.6.5"
+ // Pure-python; wheels are vendored by scripts/build-android-wheels-local.sh
+ // for offline/find-links installs (HTTPInterface / RNS-over-HTTP).
+ install "httpx[http2]==0.28.1"
}
}
}

diff --git a/docs/en/interfaces.md b/docs/en/interfaces.md
index edbc435f..97b3d276 100644
--- a/docs/en/interfaces.md
+++ b/docs/en/interfaces.md
@@ -24,6 +24,7 @@ The **Add interface** flow includes:
| KISSInterface | KISS TNC devices |
| I2PInterface | I2P-based Reticulum transport |
| AutoInterface | Automatic discovery on local networks |
+| HTTPInterface | HTTP/S tunnel (bundled RNS-over-HTTP) |
| Custom external types | Advanced setups |
Community-curated suggestions come from `community_interfaces.json`, sourced from [directory.rns.recipes](https://directory.rns.recipes).
@@ -44,6 +45,10 @@ LoRa setups often need firmware management. **Tools → RNode Flasher** opens th
MeshChatX includes a custom `WebsocketServerInterface` for WebSocket-based Reticulum transport. Use it when bridging to web-friendly gateways.
+## HTTP tunnel interface
+
+MeshChatX vendors [RNS-over-HTTP](https://github.com/Quad4-Software/RNS-over-HTTP) and installs `HTTPInterface.py` into your Reticulum `interfacepath` on startup. Use **Add interface → HTTP Tunnel** for client or server mode when only HTTP/S egress is available. Default transport is HTTP/1.1. HTTP/2 and HTTP/3 need TLS and optional extra packages on the server side.
+
## Getting onto the mesh
A minimal path for a new node:

diff --git a/docs/en/messaging.md b/docs/en/messaging.md
index 9a8df6c0..1fdd7b34 100644
--- a/docs/en/messaging.md
+++ b/docs/en/messaging.md
@@ -38,7 +38,7 @@ MeshChatX can:
- Run a **local propagation node** on your identity
- **Sync** with remote propagation nodes you trust
-- **Auto-select** a preferred node via `AutoPropagationManager`
+- **Auto-select** a preferred propagation peer via `AutoPropagationManager` from local `lxmf.propagation` announces and RNS paths (small sync probe, remembered destination hashes, no central directory)
- **Retry** failed direct deliveries through propagation when configured
Manage nodes from **Tools → Propagation nodes** or related settings entries.

diff --git a/meshchatx.rsm b/meshchatx.rsm
index ff5ae418..9f3ea792 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 0c1d565d..487a547b 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -1590,6 +1590,18 @@ class ReticulumMeshChat:
guard_invalid_rnode_txpower_in_config(config_path)
i2p_support.guard_i2p_interfaces_in_config(config_path)
ensure_safe_reticulum_runtime_flags(config_path)
+ try:
+ from meshchatx.src.backend.interface_module_store import (
+ ensure_bundled_interface_modules,
+ )
+
+ ensure_bundled_interface_modules(config_dir)
+ except Exception as exc:
+ logger.warning(
+ "Failed to sync bundled interface modules into %s: %s",
+ config_dir,
+ exc,
+ )
def _set_startup_stage(self, stage: str, error: str | None = None) -> None:
previous = getattr(self, "_startup_stage", None)
@@ -3003,6 +3015,22 @@ class ReticulumMeshChat:
def get_app_version() -> str:
return app_version
+ @staticmethod
+ def get_build_meta() -> dict:
+ """Baked git commit / channel from meshchatx.src.build_meta."""
+ try:
+ from meshchatx.src import build_meta as _build_meta
+
+ return dict(_build_meta.as_dict(app_version))
+ except Exception:
+ return {
+ "git_commit": "",
+ "git_commit_short": "",
+ "build_channel": "local",
+ "is_dev_build": False,
+ "display_version": app_version,
+ }
+
def _api_reticulum_config_path(self) -> str | None:
r = getattr(self, "reticulum", None)
if r is not None:

diff --git a/meshchatx/src/backend/auto_propagation_manager.py b/meshchatx/src/backend/auto_propagation_manager.py
index 7d551df8..fc03aadc 100644
--- a/meshchatx/src/backend/auto_propagation_manager.py
+++ b/meshchatx/src/backend/auto_propagation_manager.py
@@ -1,15 +1,37 @@
# SPDX-License-Identifier: 0BSD
+"""Auto-select a usable LXMF propagation peer from local announces and paths.
+
+Zen of Reticulum notes for this manager:
+
+- There is no privileged propagation center. Candidates are destination hashes
+ heard via lxmf.propagation announces, plus local memory of peers that already
+ proved reachable for this identity.
+- Trust is experience: a usable RNS path and a successful LXMF sync probe, not a
+ hostname, directory, or fixed server role.
+- Airtime is scarce: prefer sticky verified peers, cool down failures, probe at
+ most a few peers per cycle, and request at most one message during a probe.
+- Delay is normal: missing paths request rediscovery and keep recoverable state
+ instead of clearing the preferred hash on the first blip.
+"""
+
+from __future__ import annotations
+
import asyncio
import contextlib
+import json
import time
+from typing import Any
import RNS
from LXMF.LXMRouter import LXMRouter
from meshchatx.src.backend import reticulum_pathfinding
from meshchatx.src.backend.async_utils import AsyncUtils
-from meshchatx.src.backend.meshchat_utils import parse_lxmf_propagation_node_app_data
+from meshchatx.src.backend.meshchat_utils import (
+ parse_lxmf_propagation_node_app_data,
+ propagation_sync_idle_like,
+)
_PROP_FAILURE_STATES = frozenset(
{
@@ -22,10 +44,39 @@ _PROP_FAILURE_STATES = frozenset(
LXMRouter.PR_PATH_TIMEOUT,
},
)
+_PROP_SUCCESS_STATES = frozenset(
+ {
+ LXMRouter.PR_IDLE,
+ LXMRouter.PR_COMPLETE,
+ },
+)
PATH_WAIT_SECONDS = 40.0
SYNC_PROBE_TIMEOUT_SECONDS = 120.0
POLL_INTERVAL_SECONDS = 0.2
+CHECK_INTERVAL_SECONDS = 300
+# Keep a working preferred peer without full sync probes this often.
+REVERIFY_SECONDS = 1800
+# Avoid hammering peers that just failed a sync probe.
+FAILURE_COOLDOWN_SECONDS = 600
+# Do not switch away from a working preferred peer for a tiny hop gain.
+HOP_SWITCH_HYSTERESIS = 2
+# Clear the active outbound peer only after repeated verify failures.
+MAX_CONSECUTIVE_FAILURES_BEFORE_CLEAR = 3
+MAX_MEMORY_NODES = 12
+# Scarcity: do not walk the whole announce table in one cycle.
+MAX_PROBES_PER_CYCLE = 2
+# Scarcity: prove sync/access without pulling a bulk mailbox.
+PROBE_MAX_MESSAGES = 1
+UNKNOWN_HOPS = 10**9
+MEMORY_CONFIG_KEY = "lxmf_preferred_propagation_node_memory"
+
+
+def _pretty_dest(node_hex: str) -> str:
+ try:
+ return RNS.prettyhexrep(bytes.fromhex(node_hex))
+ except Exception:
+ return "<invalid>"
class AutoPropagationManager:
@@ -35,14 +86,16 @@ class AutoPropagationManager:
self.config = context.config
self.database = context.database
self.running = False
- self._last_check = 0
- self._check_interval = 300 # 5 minutes
+ self._check_interval = CHECK_INTERVAL_SECONDS
+ # In-process cache mirrors identity-scoped config memory.
+ self._memory: dict[str, dict[str, Any]] = {}
+ self._load_memory()
def stop(self):
self.running = False
async def _run(self):
- # Wait a bit after startup to allow discovers to come in
+ # Wait after startup so announces and paths can settle.
await asyncio.sleep(10)
self.running = True
@@ -54,19 +107,220 @@ class AutoPropagationManager:
break
except Exception as e:
print(
- f"Error in AutoPropagationManager for {self.context.identity_hash}: {e}",
+ f"Error in AutoPropagationManager: {type(e).__name__}: {e}",
)
await asyncio.sleep(self._check_interval)
- async def _wait_for_path(self, dest_hash: bytes, timeout: float) -> bool:
- r = self.app.reticulum if self.app and hasattr(self.app, "reticulum") else None
- return await reticulum_pathfinding.wait_for_path(
- r,
- dest_hash,
- timeout,
- poll_interval=POLL_INTERVAL_SECONDS,
+ def _load_memory(self) -> None:
+ raw = None
+ with contextlib.suppress(Exception):
+ raw = self.config.get(MEMORY_CONFIG_KEY, default_value=None)
+ self._memory = self._parse_memory(raw)
+
+ def _save_memory(self) -> None:
+ trimmed = self._trim_memory(self._memory)
+ self._memory = trimmed
+ payload = json.dumps(trimmed, separators=(",", ":"), sort_keys=True)
+ with contextlib.suppress(Exception):
+ self.config.set(MEMORY_CONFIG_KEY, payload)
+
+ @staticmethod
+ def _parse_memory(raw: Any) -> dict[str, dict[str, Any]]:
+ if not raw:
+ return {}
+ try:
+ data = json.loads(raw) if isinstance(raw, str) else raw
+ except (TypeError, json.JSONDecodeError, ValueError):
+ return {}
+ if not isinstance(data, dict):
+ return {}
+ out: dict[str, dict[str, Any]] = {}
+ for key, value in data.items():
+ if not isinstance(key, str) or not isinstance(value, dict):
+ continue
+ try:
+ bytes.fromhex(key)
+ except ValueError:
+ continue
+ if len(key) != (RNS.Identity.TRUNCATED_HASHLENGTH // 8) * 2:
+ continue
+ out[key.lower()] = {
+ "successes": int(value.get("successes") or 0),
+ "failures": int(value.get("failures") or 0),
+ "consecutive_failures": int(value.get("consecutive_failures") or 0),
+ "last_success_at": int(value.get("last_success_at") or 0),
+ "last_failure_at": int(value.get("last_failure_at") or 0),
+ "last_hops": int(value.get("last_hops") or UNKNOWN_HOPS),
+ }
+ return out
+
+ @staticmethod
+ def _trim_memory(memory: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
+ if len(memory) <= MAX_MEMORY_NODES:
+ return memory
+
+ def rank(item: tuple[str, dict[str, Any]]) -> tuple[int, int, int]:
+ entry = item[1]
+ return (
+ int(entry.get("last_success_at") or 0),
+ int(entry.get("successes") or 0),
+ -int(entry.get("consecutive_failures") or 0),
+ )
+
+ keep = sorted(memory.items(), key=rank, reverse=True)[:MAX_MEMORY_NODES]
+ return {key: value for key, value in keep}
+
+ def _memory_entry(self, node_hex: str) -> dict[str, Any]:
+ return self._memory.setdefault(
+ node_hex.lower(),
+ {
+ "successes": 0,
+ "failures": 0,
+ "consecutive_failures": 0,
+ "last_success_at": 0,
+ "last_failure_at": 0,
+ "last_hops": UNKNOWN_HOPS,
+ },
+ )
+
+ def _record_success(self, node_hex: str, hops: int) -> None:
+ entry = self._memory_entry(node_hex)
+ entry["successes"] = int(entry.get("successes") or 0) + 1
+ entry["consecutive_failures"] = 0
+ entry["last_success_at"] = int(time.time())
+ entry["last_hops"] = (
+ hops if hops < UNKNOWN_HOPS else entry.get("last_hops", UNKNOWN_HOPS)
)
+ self._save_memory()
+
+ def _record_failure(self, node_hex: str) -> None:
+ entry = self._memory_entry(node_hex)
+ entry["failures"] = int(entry.get("failures") or 0) + 1
+ entry["consecutive_failures"] = int(entry.get("consecutive_failures") or 0) + 1
+ entry["last_failure_at"] = int(time.time())
+ self._save_memory()
+
+ def _in_failure_cooldown(self, node_hex: str, now: float | None = None) -> bool:
+ entry = self._memory.get(node_hex.lower())
+ if not entry:
+ return False
+ if int(entry.get("consecutive_failures") or 0) <= 0:
+ return False
+ last_failure = int(entry.get("last_failure_at") or 0)
+ if last_failure <= 0:
+ return False
+ clock = time.time() if now is None else now
+ return (clock - last_failure) < FAILURE_COOLDOWN_SECONDS
+
+ def _recently_verified(self, node_hex: str, now: float | None = None) -> bool:
+ entry = self._memory.get(node_hex.lower())
+ if not entry:
+ return False
+ if int(entry.get("consecutive_failures") or 0) > 0:
+ return False
+ last_success = int(entry.get("last_success_at") or 0)
+ if last_success <= 0:
+ return False
+ clock = time.time() if now is None else now
+ return (clock - last_success) < REVERIFY_SECONDS
+
+ @staticmethod
+ def _path_quality_ok(dest_hash: bytes) -> bool:
+ try:
+ if not RNS.Transport.has_path(dest_hash):
+ return False
+ if RNS.Transport.path_is_unresponsive(dest_hash):
+ return False
+ if reticulum_pathfinding.transport_path_table_entry_is_expired(dest_hash):
+ return False
+ return True
+ except Exception:
+ return False
+
+ def _hops_to(self, dest_hash: bytes) -> int:
+ try:
+ if RNS.Transport.has_path(dest_hash):
+ hops = RNS.Transport.hops_to(dest_hash)
+ if isinstance(hops, int) and hops >= 0:
+ return hops
+ except Exception:
+ pass
+ return UNKNOWN_HOPS
+
+ def _candidate_score(
+ self,
+ node_hex: str,
+ hops: int,
+ path_ok: bool,
+ from_announce: bool,
+ now: float,
+ ) -> float:
+ """Lower is better. Announced presence beats memory-only ghosts."""
+ score = float(hops if hops < UNKNOWN_HOPS else 64)
+ if not path_ok:
+ score += 80.0
+ if not from_announce:
+ # Announces are presence. Memory is only a recovery hint.
+ score += 12.0
+
+ entry = self._memory.get(node_hex.lower())
+ if entry:
+ successes = int(entry.get("successes") or 0)
+ consecutive = int(entry.get("consecutive_failures") or 0)
+ last_success = int(entry.get("last_success_at") or 0)
+ score -= min(successes, 8) * 0.75
+ score += consecutive * 6.0
+ if last_success > 0:
+ age = max(0.0, now - last_success)
+ score -= max(0.0, 4.0 - (age / 900.0))
+ if self._in_failure_cooldown(node_hex, now=now):
+ score += 1000.0
+ return score
+
+ async def _wait_for_usable_path(self, dest_hash: bytes, timeout: float) -> bool:
+ r = self.app.reticulum if self.app and hasattr(self.app, "reticulum") else None
+ reticulum_pathfinding.prepare_fresh_path_request(r, dest_hash)
+ deadline = time.monotonic() + timeout
+ nudged = False
+ while time.monotonic() < deadline:
+ if self._path_quality_ok(dest_hash):
+ return True
+ try:
+ has_path = RNS.Transport.has_path(dest_hash)
+ except Exception:
+ has_path = False
+ if has_path and not nudged:
+ # Stale or unresponsive path: drop and request again once.
+ if r is not None:
+ with contextlib.suppress(Exception):
+ r.drop_path(dest_hash)
+ else:
+ with contextlib.suppress(Exception):
+ RNS.Transport.expire_path(dest_hash)
+ reticulum_pathfinding.nudge_path_request(dest_hash)
+ nudged = True
+ elif (
+ not has_path
+ and not nudged
+ and (deadline - time.monotonic()) < (timeout * 0.45)
+ ):
+ reticulum_pathfinding.nudge_path_request(dest_hash)
+ nudged = True
+ await asyncio.sleep(POLL_INTERVAL_SECONDS)
+ return self._path_quality_ok(dest_hash)
+
+ async def _settle_router_idle(self, router, seconds: float = 5.0) -> None:
+ settle_deadline = time.monotonic() + seconds
+ while time.monotonic() < settle_deadline:
+ if propagation_sync_idle_like(router.propagation_transfer_state):
+ with contextlib.suppress(Exception):
+ if router.propagation_transfer_state == LXMRouter.PR_COMPLETE:
+ router.propagation_transfer_state = LXMRouter.PR_IDLE
+ return
+ await asyncio.sleep(POLL_INTERVAL_SECONDS)
+ with contextlib.suppress(Exception):
+ router.propagation_transfer_state = LXMRouter.PR_IDLE
async def _probe_propagation_sync(self, node_hex: str) -> bool:
ctx = self.context
@@ -80,48 +334,51 @@ class AutoPropagationManager:
except Exception:
return False
- # Ensure any previous sync is fully cancelled and state is idle
+ # Cancel any previous sync and wait for idle so stale non-idle state
+ # cannot look like a successful probe.
self.app.stop_propagation_node_sync(context=ctx)
- settle_deadline = time.monotonic() + 5.0
- while time.monotonic() < settle_deadline:
- if router.propagation_transfer_state == LXMRouter.PR_IDLE:
- break
- await asyncio.sleep(POLL_INTERVAL_SECONDS)
- else:
- with contextlib.suppress(Exception):
- router.propagation_transfer_state = LXMRouter.PR_IDLE
+ await self._settle_router_idle(router)
try:
router.set_outbound_propagation_node(dest)
except Exception:
return False
- router.request_messages_from_propagation_node(ctx.identity)
+ # LXMF needs a recalled identity to open the propagation link.
+ with contextlib.suppress(Exception):
+ if RNS.Identity.recall(dest) is None:
+ RNS.Transport.request_path(dest)
+
+ # Prove access with a tiny transfer budget. Full mailbox sync belongs
+ # to the operator-triggered or interval sync paths, not auto-select.
+ router.request_messages_from_propagation_node(
+ ctx.identity,
+ max_messages=PROBE_MAX_MESSAGES,
+ )
deadline = time.monotonic() + SYNC_PROBE_TIMEOUT_SECONDS
+ left_idle = False
- # Wait for the sync to actually start (leave idle)
while time.monotonic() < deadline:
state = router.propagation_transfer_state
if state in _PROP_FAILURE_STATES:
self.app.stop_propagation_node_sync(context=ctx)
return False
- if state != LXMRouter.PR_IDLE:
+ if state not in _PROP_SUCCESS_STATES:
+ left_idle = True
break
await asyncio.sleep(POLL_INTERVAL_SECONDS)
else:
- # Never left idle -> failed to start
self.app.stop_propagation_node_sync(context=ctx)
return False
- # Wait for the sync to finish (return to idle)
while time.monotonic() < deadline:
state = router.propagation_transfer_state
if state in _PROP_FAILURE_STATES:
self.app.stop_propagation_node_sync(context=ctx)
return False
- if state == LXMRouter.PR_IDLE:
- # Success: we left idle and now we're back
+ # LXMF ends a successful sync at PR_COMPLETE (not always PR_IDLE).
+ if left_idle and state in _PROP_SUCCESS_STATES:
self.app.stop_propagation_node_sync(context=ctx)
return True
await asyncio.sleep(POLL_INTERVAL_SECONDS)
@@ -129,6 +386,127 @@ class AutoPropagationManager:
self.app.stop_propagation_node_sync(context=ctx)
return False
+ def _collect_candidates(self) -> tuple[dict[str, int], set[str]]:
+ """Return destination_hex -> hops, plus the set heard via announces."""
+ announces = self.database.announces.get_announces(aspect="lxmf.propagation")
+ best_by_hex: dict[str, int] = {}
+ announced: set[str] = set()
+
+ for announce in announces or []:
+ dest_hex = announce.get("destination_hash")
+ if not isinstance(dest_hex, str):
+ continue
+ dest_hex = dest_hex.lower()
+ node_data = parse_lxmf_propagation_node_app_data(announce.get("app_data"))
+ if not node_data or not node_data.get("enabled", False):
+ continue
+ try:
+ dest_hash = bytes.fromhex(dest_hex)
+ except ValueError:
+ continue
+ hops = self._hops_to(dest_hash)
+ prev = best_by_hex.get(dest_hex)
+ if prev is None or hops < prev:
+ best_by_hex[dest_hex] = hops
+ announced.add(dest_hex)
+
+ # Remembered peers stay candidates when announces aged out, but they are
+ # recovery hints only, never a global registry.
+ now = time.time()
+ for node_hex, entry in self._memory.items():
+ if node_hex in best_by_hex:
+ continue
+ if int(entry.get("successes") or 0) <= 0:
+ continue
+ if int(entry.get("consecutive_failures") or 0) >= (
+ MAX_CONSECUTIVE_FAILURES_BEFORE_CLEAR
+ ):
+ continue
+ last_success = int(entry.get("last_success_at") or 0)
+ if last_success <= 0 or (now - last_success) > (REVERIFY_SECONDS * 8):
+ continue
+ try:
+ dest_hash = bytes.fromhex(node_hex)
+ except ValueError:
+ continue
+ best_by_hex[node_hex] = self._hops_to(dest_hash)
+
+ return best_by_hex, announced
+
+ def _ordered_candidates(
+ self,
+ best_by_hex: dict[str, int],
+ announced: set[str],
+ previous_hex: str | None,
+ ) -> list[tuple[float, int, str]]:
+ now = time.time()
+ scored: list[tuple[float, int, str]] = []
+ for node_hex, hops in best_by_hex.items():
+ try:
+ dest_hash = bytes.fromhex(node_hex)
+ except ValueError:
+ continue
+ path_ok = self._path_quality_ok(dest_hash)
+ score = self._candidate_score(
+ node_hex,
+ hops,
+ path_ok,
+ node_hex in announced,
+ now,
+ )
+ scored.append((score, hops, node_hex))
+
+ scored.sort(key=lambda item: (item[0], item[1], item[2]))
+
+ ordered: list[tuple[float, int, str]] = []
+ seen: set[str] = set()
+ if previous_hex and previous_hex in best_by_hex:
+ for item in scored:
+ if item[2] == previous_hex:
+ ordered.append(item)
+ seen.add(previous_hex)
+ break
+ for item in scored:
+ if item[2] not in seen:
+ ordered.append(item)
+ seen.add(item[2])
+ return ordered
+
+ def _should_keep_previous_without_probe(
+ self,
+ previous_hex: str | None,
+ best_by_hex: dict[str, int],
+ ordered: list[tuple[float, int, str]],
+ ) -> bool:
+ if not previous_hex or previous_hex not in best_by_hex:
+ return False
+ try:
+ dest_hash = bytes.fromhex(previous_hex)
+ except ValueError:
+ return False
+ if not self._path_quality_ok(dest_hash):
+ return False
+ if not self._recently_verified(previous_hex):
+ return False
+
+ previous_hops = best_by_hex[previous_hex]
+ # Stay sticky unless another candidate is clearly closer and path-usable.
+ for _score, hops, node_hex in ordered:
+ if node_hex == previous_hex:
+ continue
+ if hops >= UNKNOWN_HOPS:
+ continue
+ try:
+ other_hash = bytes.fromhex(node_hex)
+ except ValueError:
+ continue
+ if not self._path_quality_ok(other_hash):
+ continue
+ if hops + HOP_SWITCH_HYSTERESIS < previous_hops:
+ return False
+ break
+ return True
+
async def check_and_update_propagation_node(self):
ctx = self.context
router = ctx.message_router
@@ -138,103 +516,98 @@ class AutoPropagationManager:
previous_hex = (
self.config.lxmf_preferred_propagation_node_destination_hash.get()
)
+ if isinstance(previous_hex, str):
+ previous_hex = previous_hex.lower()
+ else:
+ previous_hex = None
- # If a sync is in progress, only interrupt it when the current node
- # appears unreachable or the path is stale/unresponsive. This prevents
- # getting stuck on a node we cannot actually reach.
- if router.propagation_transfer_state != LXMRouter.PR_IDLE:
+ # If a sync is in progress, only interrupt when the current peer path
+ # looks unreachable or stale. PR_COMPLETE is idle-like (finished).
+ if not propagation_sync_idle_like(router.propagation_transfer_state):
current_path_ok = False
if previous_hex:
try:
current_dest = bytes.fromhex(previous_hex)
- current_has_path = RNS.Transport.has_path(current_dest)
- current_path_ok = (
- current_has_path
- and not RNS.Transport.path_is_unresponsive(current_dest)
- and not reticulum_pathfinding.transport_path_table_entry_is_expired(
- current_dest,
- )
- )
+ current_path_ok = self._path_quality_ok(current_dest)
except Exception:
- pass
+ current_path_ok = False
if current_path_ok:
- # Sync is likely making progress. Let it finish.
return
- # Current node is unreachable. Stop the stuck sync so we can
- # look for a working alternative.
self.app.stop_propagation_node_sync(context=ctx)
- # Wait briefly for the router to settle back to idle before we
- # start probing other nodes.
- settle_deadline = time.monotonic() + 5.0
- while time.monotonic() < settle_deadline:
- if router.propagation_transfer_state == LXMRouter.PR_IDLE:
- break
- await asyncio.sleep(POLL_INTERVAL_SECONDS)
- else:
- with contextlib.suppress(Exception):
- router.propagation_transfer_state = LXMRouter.PR_IDLE
+ await self._settle_router_idle(router)
- announces = self.database.announces.get_announces(aspect="lxmf.propagation")
+ best_by_hex, announced = self._collect_candidates()
+ if not best_by_hex:
+ return
- best_by_hex: dict[str, tuple[int, str]] = {}
- for announce in announces:
- dest_hex = announce["destination_hash"]
- node_data = parse_lxmf_propagation_node_app_data(announce["app_data"])
- if not node_data or not node_data.get("enabled", False):
- continue
- try:
- dest_hash = bytes.fromhex(dest_hex)
- except ValueError:
- continue
- if RNS.Transport.has_path(dest_hash):
- hops = RNS.Transport.hops_to(dest_hash)
- else:
- hops = 10**9
- prev = best_by_hex.get(dest_hex)
- if prev is None or hops < prev[0]:
- best_by_hex[dest_hex] = (hops, dest_hex)
+ ordered = self._ordered_candidates(best_by_hex, announced, previous_hex)
- sorted_candidates = sorted(best_by_hex.values(), key=lambda x: x[0])
- if not sorted_candidates:
+ if self._should_keep_previous_without_probe(previous_hex, best_by_hex, ordered):
+ # Keep the verified preferred peer without a disruptive sync probe.
+ outbound = None
+ with contextlib.suppress(Exception):
+ outbound = router.get_outbound_propagation_node()
+ if outbound is None or (
+ isinstance(outbound, (bytes, bytearray))
+ and outbound.hex() != previous_hex
+ ):
+ self.app.set_active_propagation_node(previous_hex, context=self.context)
+ else:
+ with contextlib.suppress(Exception):
+ RNS.Transport.request_path(bytes.fromhex(previous_hex))
return
- ordered: list[tuple[int, str]] = []
- seen_hex: set[str] = set()
- if previous_hex and previous_hex in best_by_hex:
- ordered.append(best_by_hex[previous_hex])
- seen_hex.add(previous_hex)
- for item in sorted_candidates:
- if item[1] not in seen_hex:
- ordered.append(item)
- seen_hex.add(item[1])
-
- for _hops, node_hex in ordered:
+ probed_any = False
+ probes = 0
+ for _score, hops, node_hex in ordered:
+ if probes >= MAX_PROBES_PER_CYCLE:
+ break
+ if self._in_failure_cooldown(node_hex) and node_hex != previous_hex:
+ continue
try:
dest_hash = bytes.fromhex(node_hex)
except ValueError:
continue
- if not await self._wait_for_path(dest_hash, PATH_WAIT_SECONDS):
+ if not await self._wait_for_usable_path(dest_hash, PATH_WAIT_SECONDS):
+ self._record_failure(node_hex)
continue
+ probes += 1
+ probed_any = True
if not await self._probe_propagation_sync(node_hex):
+ self._record_failure(node_hex)
continue
+ hops = self._hops_to(dest_hash)
+ self._record_success(node_hex, hops)
if node_hex != previous_hex:
print(
- f"Auto-propagation: Switching to verified node {node_hex} "
- f"for {self.context.identity_hash}",
- )
- self.app.set_active_propagation_node(node_hex, context=self.context)
- self.config.lxmf_preferred_propagation_node_destination_hash.set(
- node_hex,
+ "Auto-propagation: switching to verified peer "
+ f"{_pretty_dest(node_hex)}",
)
+ self.app.set_active_propagation_node(node_hex, context=self.context)
+ self.config.lxmf_preferred_propagation_node_destination_hash.set(node_hex)
+ AsyncUtils.run_async(
+ self.app.send_config_to_websocket_clients(context=ctx),
+ )
+ return
+
+ # Nothing verified this cycle. Keep a recently-good preferred hash when
+ # possible so the next check can retry without wiping operator state.
+ if previous_hex:
+ entry = self._memory.get(previous_hex, {})
+ consecutive = int(entry.get("consecutive_failures") or 0)
+ if consecutive < MAX_CONSECUTIVE_FAILURES_BEFORE_CLEAR:
+ with contextlib.suppress(Exception):
+ RNS.Transport.request_path(bytes.fromhex(previous_hex))
+ self.app.set_active_propagation_node(previous_hex, context=self.context)
+ return
+
+ if probed_any or previous_hex:
+ self.app.remove_active_propagation_node(context=self.context)
+ if previous_hex:
+ self.config.lxmf_preferred_propagation_node_destination_hash.set(None)
AsyncUtils.run_async(
self.app.send_config_to_websocket_clients(context=ctx),
)
- return
-
- # None of the candidates worked (including the previous node if it was
- # probed). Clear the active node rather than restoring a sync-broken one
- # just because a transport path still exists.
- self.app.remove_active_propagation_node(context=self.context)

diff --git a/meshchatx/src/backend/data/THIRD_PARTY_NOTICES.txt b/meshchatx/src/backend/data/THIRD_PARTY_NOTICES.txt
index 3b6fae8d..0f054d80 100644
--- a/meshchatx/src/backend/data/THIRD_PARTY_NOTICES.txt
+++ b/meshchatx/src/backend/data/THIRD_PARTY_NOTICES.txt
@@ -52,6 +52,10 @@ lxmf 1.1.0
lxmfy 2.0.1
License: BSD-0-Clause
Author: Quad4 <team@quad4.io>
+RNS-over-HTTP (vendored HTTPInterface)
+ License: MIT
+ Upstream: https://github.com/Quad4-Software/RNS-over-HTTP
+ Bundled revision: 530aca499fb7442910f691629670f6f9f4b67221
lxst 0.5.0
License: Other/Proprietary License
Author: Mark Qvist

diff --git a/meshchatx/src/backend/data/interfaces/HTTPInterface.LICENSE b/meshchatx/src/backend/data/interfaces/HTTPInterface.LICENSE
new file mode 100644
index 00000000..24a9a940
--- /dev/null
+++ b/meshchatx/src/backend/data/interfaces/HTTPInterface.LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Ivan / Quad4.io
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

diff --git a/meshchatx/src/backend/data/interfaces/HTTPInterface.py b/meshchatx/src/backend/data/interfaces/HTTPInterface.py
new file mode 100755
index 00000000..d1bd80bb
--- /dev/null
+++ b/meshchatx/src/backend/data/interfaces/HTTPInterface.py
@@ -0,0 +1,897 @@
+import asyncio
+import os
+import socket
+import ssl
+import threading
+from collections import deque
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from queue import Empty, Queue
+from socketserver import ThreadingMixIn
+from urllib.parse import urlparse
+
+import httpx
+import RNS
+from RNS.Interfaces.Interface import Interface
+
+
+class HDLC:
+ """Pipe-compatible simplified HDLC framing for HTTP bodies.
+
+ Same FLAG/ESC scheme as RNS PipeInterface so multiple packets can
+ share one HTTP request or response without losing boundaries.
+ """
+
+ FLAG = 0x7E
+ ESC = 0x7D
+ ESC_MASK = 0x20
+
+ @staticmethod
+ def escape(data):
+ data = data.replace(
+ bytes([HDLC.ESC]),
+ bytes([HDLC.ESC, HDLC.ESC ^ HDLC.ESC_MASK]),
+ )
+ data = data.replace(
+ bytes([HDLC.FLAG]),
+ bytes([HDLC.ESC, HDLC.FLAG ^ HDLC.ESC_MASK]),
+ )
+ return data
+
+ @staticmethod
+ def frame(packet):
+ return bytes([HDLC.FLAG]) + HDLC.escape(packet) + bytes([HDLC.FLAG])
+
+ @staticmethod
+ def deframe(buffer, max_frame_len):
+ """Return (packets, remainder) from an HDLC byte buffer."""
+ packets = []
+ in_frame = False
+ escape = False
+ data_buffer = bytearray()
+ i = 0
+ last_complete = 0
+
+ while i < len(buffer):
+ byte = buffer[i]
+ i += 1
+
+ if in_frame and byte == HDLC.FLAG:
+ packets.append(bytes(data_buffer))
+ in_frame = False
+ escape = False
+ data_buffer = bytearray()
+ last_complete = i
+ elif byte == HDLC.FLAG:
+ in_frame = True
+ escape = False
+ data_buffer = bytearray()
+ elif in_frame and len(data_buffer) < max_frame_len:
+ if byte == HDLC.ESC:
+ escape = True
+ else:
+ if escape:
+ if byte == HDLC.FLAG ^ HDLC.ESC_MASK:
+ byte = HDLC.FLAG
+ elif byte == HDLC.ESC ^ HDLC.ESC_MASK:
+ byte = HDLC.ESC
+ escape = False
+ data_buffer.append(byte)
+
+ remainder = buffer[last_complete:] if in_frame else b""
+ return packets, remainder
+
+
+class _H3Session:
+ """Persistent HTTP/3 client over one QUIC connection."""
+
+ def __init__(self, server_url, user_agent, verify=True, ca_certs=None):
+ from aioquic.asyncio import connect
+ from aioquic.asyncio.protocol import QuicConnectionProtocol
+ from aioquic.h3.connection import H3_ALPN, H3Connection
+ from aioquic.h3.events import DataReceived, HeadersReceived
+ from aioquic.quic.configuration import QuicConfiguration
+ from aioquic.quic.events import QuicEvent
+
+ parsed = urlparse(server_url)
+ if parsed.scheme != "https":
+ raise ValueError("HTTP/3 requires an https:// server_url")
+
+ self._host = parsed.hostname
+ self._port = parsed.port or 443
+ self._path = parsed.path or "/"
+ if parsed.query:
+ self._path += "?" + parsed.query
+ self._authority = parsed.netloc
+ self._user_agent = user_agent
+ self._closed = False
+ self._request_count = 0
+
+ configuration = QuicConfiguration(is_client=True, alpn_protocols=H3_ALPN)
+ if not verify:
+ configuration.verify_mode = ssl.CERT_NONE
+ elif ca_certs:
+ configuration.load_verify_locations(ca_certs)
+
+ class _Client(QuicConnectionProtocol):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._http = H3Connection(self._quic)
+ self._request_events = {}
+ self._request_waiter = {}
+
+ def http_event_received(self, event):
+ if isinstance(event, (HeadersReceived, DataReceived)):
+ stream_id = event.stream_id
+ if stream_id in self._request_events:
+ self._request_events[stream_id].append(event)
+ if event.stream_ended:
+ waiter = self._request_waiter.pop(stream_id)
+ waiter.set_result(self._request_events.pop(stream_id))
+
+ def quic_event_received(self, event: QuicEvent):
+ for http_event in self._http.handle_event(event):
+ self.http_event_received(http_event)
+
+ async def post_bytes(self, path, authority, user_agent, data):
+ stream_id = self._quic.get_next_available_stream_id()
+ headers = [
+ (b":method", b"POST"),
+ (b":scheme", b"https"),
+ (b":authority", authority.encode()),
+ (b":path", path.encode()),
+ (b"user-agent", user_agent.encode()),
+ (b"content-type", b"application/octet-stream"),
+ (b"content-length", str(len(data)).encode()),
+ ]
+ self._http.send_headers(
+ stream_id=stream_id,
+ headers=headers,
+ end_stream=not data,
+ )
+ if data:
+ self._http.send_data(
+ stream_id=stream_id, data=data, end_stream=True
+ )
+
+ waiter = self._loop.create_future()
+ self._request_events[stream_id] = deque()
+ self._request_waiter[stream_id] = waiter
+ self.transmit()
+ events = await asyncio.shield(waiter)
+
+ status = 0
+ body = b""
+ for event in events:
+ if isinstance(event, HeadersReceived):
+ for key, value in event.headers:
+ if key == b":status":
+ status = int(value.decode())
+ elif isinstance(event, DataReceived):
+ body += event.data
+ return status, body
+
+ self._Client = _Client
+ self._connect = connect
+ self._configuration = configuration
+ self._loop = asyncio.new_event_loop()
+ self._loop_thread = threading.Thread(
+ target=self._run_loop,
+ name="h3-client-loop",
+ daemon=True,
+ )
+ self._ready = threading.Event()
+ self._error = None
+ self._client = None
+ self._cm = None
+ self._loop_thread.start()
+ if not self._ready.wait(timeout=30):
+ raise TimeoutError("HTTP/3 client failed to connect")
+ if self._error is not None:
+ raise self._error
+
+ def _run_loop(self):
+ asyncio.set_event_loop(self._loop)
+ try:
+ self._loop.run_until_complete(self._open())
+ self._ready.set()
+ self._loop.run_forever()
+ except Exception as exc:
+ self._error = exc
+ self._ready.set()
+
+ async def _open(self):
+ self._cm = self._connect(
+ self._host,
+ self._port,
+ configuration=self._configuration,
+ create_protocol=self._Client,
+ )
+ self._client = await self._cm.__aenter__()
+
+ def post(self, data, timeout=10.0):
+ if self._closed:
+ raise RuntimeError("HTTP/3 session is closed")
+
+ async def _do_post():
+ status, body = await self._client.post_bytes(
+ self._path,
+ self._authority,
+ self._user_agent,
+ data,
+ )
+ self._request_count += 1
+ if status >= 400:
+ raise RuntimeError(f"HTTP/3 status {status}")
+ return body
+
+ fut = asyncio.run_coroutine_threadsafe(_do_post(), self._loop)
+ return fut.result(timeout=timeout)
+
+ def close(self):
+ if self._closed:
+ return
+ self._closed = True
+
+ async def _close():
+ if self._cm is not None:
+ await self._cm.__aexit__(None, None, None)
+
+ try:
+ fut = asyncio.run_coroutine_threadsafe(_close(), self._loop)
+ fut.result(timeout=5)
+ except Exception:
+ pass
+ self._loop.call_soon_threadsafe(self._loop.stop)
+ self._loop_thread.join(timeout=5)
+
+
+class HTTPTunnelInterface(Interface):
+ """HTTP Tunnel Interface for Reticulum.
+
+ Bidirectional RNS transport over HTTP POST with pipe-compatible HDLC
+ framing. Supports HTTP/1.1 (default), HTTP/2, and HTTP/3.
+
+ Engineering defaults:
+ - http_version=1 uses a stdlib HTTP/1.1 server and httpx client.
+ Works over cleartext http:// and is ideal behind Caddy/nginx.
+ - http_version=2 requires TLS and uses Hypercorn (h2) + httpx(http2).
+ - http_version=3 requires TLS and uses Hypercorn QUIC + a persistent
+ aioquic HTTP/3 client session.
+
+ Configuration highlights:
+ http_version: 1, 2, or 3 (default: 1)
+ tls_certfile / tls_keyfile: required for versions 2 and 3 on server
+ tls_verify: verify TLS certs on client (default: true)
+ tls_ca_certs: optional CA bundle path for client verify
+ server_url: use https:// for versions 2 and 3
+ """
+
+ DEFAULT_IFAC_SIZE = 16
+ BITRATE_GUESS = 10_000_000
+ AUTOCONFIGURE_MTU = True
+
+ DEFAULT_MTU = 4096
+ TUNNEL_USER_AGENT = "RNS-HTTP-Tunnel/1.0"
+ DEFAULT_POLL_INTERVAL = 0.1
+ DEFAULT_POOL_CONNECTIONS = 1
+ DEFAULT_POOL_MAXSIZE = 1
+ DEFAULT_KEEPALIVE_TIMEOUT = 60
+ DEFAULT_HTTP_VERSION = 1
+
+ def __init__(self, owner, configuration):
+ super().__init__()
+
+ ifconf = Interface.get_config_obj(configuration)
+
+ self.name = ifconf["name"]
+
+ mode = str(ifconf["mode"]).lower() if "mode" in ifconf else "client"
+ listen_host = ifconf["listen_host"] if "listen_host" in ifconf else "0.0.0.0"
+ listen_port = int(ifconf["listen_port"]) if "listen_port" in ifconf else 8080
+ server_url = ifconf["server_url"] if "server_url" in ifconf else None
+ poll_interval = (
+ float(ifconf["poll_interval"])
+ if "poll_interval" in ifconf
+ else self.DEFAULT_POLL_INTERVAL
+ )
+ check_user_agent = (
+ ifconf.as_bool("check_user_agent") if "check_user_agent" in ifconf else True
+ )
+ user_agent = (
+ str(ifconf["user_agent"])
+ if "user_agent" in ifconf
+ else self.TUNNEL_USER_AGENT
+ )
+ mtu = int(ifconf["mtu"]) if "mtu" in ifconf else self.DEFAULT_MTU
+ serve_html_page = (
+ ifconf.as_bool("serve_html_page") if "serve_html_page" in ifconf else False
+ )
+ html_file_path = (
+ ifconf["html_file_path"] if "html_file_path" in ifconf else None
+ )
+ pool_connections = (
+ int(ifconf["pool_connections"])
+ if "pool_connections" in ifconf
+ else self.DEFAULT_POOL_CONNECTIONS
+ )
+ pool_maxsize = (
+ int(ifconf["pool_maxsize"])
+ if "pool_maxsize" in ifconf
+ else self.DEFAULT_POOL_MAXSIZE
+ )
+ keepalive_timeout = (
+ int(ifconf["keepalive_timeout"])
+ if "keepalive_timeout" in ifconf
+ else self.DEFAULT_KEEPALIVE_TIMEOUT
+ )
+ http_version = (
+ int(ifconf["http_version"])
+ if "http_version" in ifconf
+ else self.DEFAULT_HTTP_VERSION
+ )
+ tls_certfile = ifconf["tls_certfile"] if "tls_certfile" in ifconf else None
+ tls_keyfile = ifconf["tls_keyfile"] if "tls_keyfile" in ifconf else None
+ tls_verify = ifconf.as_bool("tls_verify") if "tls_verify" in ifconf else True
+ tls_ca_certs = ifconf["tls_ca_certs"] if "tls_ca_certs" in ifconf else None
+
+ self.mode = mode
+ self.http_version = http_version
+
+ if mode not in ["client", "server"]:
+ raise ValueError(
+ f"Invalid mode '{mode}' for {self}. Must be 'client' or 'server'",
+ )
+
+ if http_version not in (1, 2, 3):
+ raise ValueError(
+ f"Invalid http_version '{http_version}' for {self}. Must be 1, 2, or 3",
+ )
+
+ if mode == "client" and server_url is None:
+ raise ValueError(f"No server_url specified for client mode in {self}")
+
+ if pool_connections < 1 or pool_maxsize < 1:
+ raise ValueError(
+ f"pool_connections and pool_maxsize must be >= 1 for {self}",
+ )
+
+ if http_version >= 2:
+ if mode == "server" and (not tls_certfile or not tls_keyfile):
+ raise ValueError(
+ f"tls_certfile and tls_keyfile are required for "
+ f"HTTP/{http_version} server mode in {self}",
+ )
+ if mode == "client":
+ parsed = urlparse(server_url)
+ if parsed.scheme != "https":
+ raise ValueError(
+ f"HTTP/{http_version} client requires https:// server_url in {self}",
+ )
+
+ self.owner = owner
+ self.IN = True
+ self.mtu = mtu
+ self.check_user_agent = check_user_agent
+ self.user_agent = user_agent
+ self.serve_html_page = serve_html_page
+ self.html_file_path = html_file_path
+ self.html_content = None
+ self.pool_connections = pool_connections
+ self.pool_maxsize = pool_maxsize
+ self.keepalive_timeout = keepalive_timeout
+ self.tls_certfile = tls_certfile
+ self.tls_keyfile = tls_keyfile
+ self.tls_verify = tls_verify
+ self.tls_ca_certs = tls_ca_certs
+ self._tcp_accepts = 0
+ self._http_requests = 0
+ self._stats_lock = threading.Lock()
+ self._last_http_version = None
+ self._client_requests = 0
+ self.session = None
+ self._h3_session = None
+ self._asgi_shutdown = None
+ self._asgi_loop = None
+
+ if self.serve_html_page and self.html_file_path:
+ self._load_html_content()
+
+ self._recv_queue = Queue()
+ self._send_queue = Queue()
+ self._stop_event = threading.Event()
+ self._frame_remainder = b""
+ self._frame_lock = threading.Lock()
+
+ self.HW_MTU = mtu
+ self.online = False
+ self.bitrate = HTTPTunnelInterface.BITRATE_GUESS
+
+ if mode == "server":
+ self.listen_host = listen_host
+ self.listen_port = listen_port
+ else:
+ self.server_url = server_url
+ self.poll_interval = poll_interval
+
+ self.optimise_mtu()
+
+ if mode == "server":
+ if http_version == 1:
+ self.setup_server_http1()
+ else:
+ self.setup_server_asgi()
+ else:
+ self.setup_client()
+
+ def _load_html_content(self):
+ try:
+ if os.path.isfile(self.html_file_path):
+ with open(self.html_file_path, encoding="utf-8") as f:
+ self.html_content = f.read()
+ RNS.log(f"Loaded HTML content from {self.html_file_path}", RNS.LOG_INFO)
+ else:
+ RNS.log(f"HTML file not found: {self.html_file_path}", RNS.LOG_WARNING)
+ self.html_content = None
+ except Exception as e:
+ RNS.log(
+ f"Error loading HTML file {self.html_file_path}: {e}",
+ RNS.LOG_ERROR,
+ )
+ self.html_content = None
+
+ def _drain_send_frames(self):
+ parts = []
+ while not self._send_queue.empty():
+ try:
+ packet = self._send_queue.get_nowait()
+ except Empty:
+ break
+ if packet:
+ parts.append(HDLC.frame(packet))
+ return b"".join(parts)
+
+ def _ingest_wire_bytes(self, wire_data):
+ if not wire_data:
+ return
+
+ with self._frame_lock:
+ buffer = self._frame_remainder + wire_data
+ packets, self._frame_remainder = HDLC.deframe(buffer, self.HW_MTU)
+
+ for packet in packets:
+ if packet:
+ self._recv_queue.put(packet)
+
+ def _record_http_request(self):
+ with self._stats_lock:
+ self._http_requests += 1
+
+ def _handle_tunnel_exchange(self, method, path, headers, body):
+ """Shared request handler for HTTP/1.1 and ASGI paths.
+
+ Returns (status, content_type, response_body).
+ """
+ self._record_http_request()
+ headers_l = {str(k).lower(): str(v) for k, v in headers.items()}
+
+ if method == "GET" and path == "/":
+ if self.serve_html_page and self.html_content:
+ return (
+ 200,
+ "text/html; charset=utf-8",
+ self.html_content.encode("utf-8"),
+ )
+ return 404, "text/plain", b""
+
+ if method == "POST" and path == "/":
+ if self.check_user_agent:
+ user_agent = headers_l.get("user-agent", "")
+ if user_agent != self.user_agent:
+ RNS.log(
+ f"Rejected request with invalid User-Agent: {user_agent}",
+ RNS.LOG_WARNING,
+ )
+ return 403, "text/plain", b"Forbidden"
+
+ if body:
+ RNS.log(f"Received {len(body)} bytes from client", RNS.LOG_EXTREME)
+ self._ingest_wire_bytes(body)
+
+ server_data = self._drain_send_frames()
+ if server_data:
+ RNS.log(
+ f"Sending {len(server_data)} framed bytes to client",
+ RNS.LOG_EXTREME,
+ )
+ return 200, "application/octet-stream", server_data
+
+ return 404, "text/plain", b""
+
+ def connection_stats(self):
+ with self._stats_lock:
+ stats = {
+ "tcp_accepts": self._tcp_accepts,
+ "http_requests": self._http_requests,
+ "client_requests": self._client_requests,
+ "last_http_version": self._last_http_version,
+ "configured_http_version": self.http_version,
+ }
+ if self._h3_session is not None:
+ stats["pool_num_connections"] = 0 if self._h3_session._closed else 1
+ stats["pool_num_requests"] = self._h3_session._request_count
+ return stats
+
+ def _build_asgi_app(self):
+ interface = self
+
+ async def app(scope, receive, send):
+ if scope["type"] != "http":
+ return
+
+ headers = {
+ key.decode("latin1"): value.decode("latin1")
+ for key, value in scope.get("headers", [])
+ }
+ body = b""
+ while True:
+ message = await receive()
+ body += message.get("body", b"")
+ if not message.get("more_body", False):
+ break
+
+ status, content_type, response_body = interface._handle_tunnel_exchange(
+ scope.get("method", "GET"),
+ scope.get("path", "/"),
+ headers,
+ body,
+ )
+ await send(
+ {
+ "type": "http.response.start",
+ "status": status,
+ "headers": [
+ (b"content-type", content_type.encode()),
+ (b"content-length", str(len(response_body)).encode()),
+ ],
+ }
+ )
+ await send({"type": "http.response.body", "body": response_body})
+
+ return app
+
+ def setup_server_http1(self):
+ interface_instance = self
+
+ class TunnelRequestHandler(BaseHTTPRequestHandler):
+ protocol_version = "HTTP/1.1"
+
+ def _send_common_headers(self, content_type, content_length):
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(content_length))
+ self.send_header("Connection", "keep-alive")
+ self.send_header(
+ "Keep-Alive",
+ f"timeout={interface_instance.keepalive_timeout}, max=1000",
+ )
+
+ def do_GET(self):
+ status, content_type, body = interface_instance._handle_tunnel_exchange(
+ "GET",
+ self.path,
+ self.headers,
+ b"",
+ )
+ self.send_response(status)
+ self._send_common_headers(content_type, len(body))
+ self.end_headers()
+ if body:
+ self.wfile.write(body)
+
+ def do_POST(self):
+ content_length = int(self.headers.get("Content-Length", 0))
+ body = self.rfile.read(content_length) if content_length > 0 else b""
+ status, content_type, response = (
+ interface_instance._handle_tunnel_exchange(
+ "POST",
+ self.path,
+ self.headers,
+ body,
+ )
+ )
+ self.send_response(status)
+ self._send_common_headers(content_type, len(response))
+ self.end_headers()
+ if response:
+ self.wfile.write(response)
+
+ def log_message(self, fmt, *args):
+ pass
+
+ class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
+ daemon_threads = True
+ allow_reuse_address = True
+
+ def get_request(self):
+ request, client_address = super().get_request()
+ with interface_instance._stats_lock:
+ interface_instance._tcp_accepts += 1
+ try:
+ request.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
+ except OSError:
+ pass
+ return request, client_address
+
+ def run_server():
+ try:
+ self._http_server = ThreadedHTTPServer(
+ (self.listen_host, self.listen_port),
+ TunnelRequestHandler,
+ )
+ self._http_server.daemon_threads = True
+ self._http_server.serve_forever()
+ except Exception as e:
+ if not self._stop_event.is_set():
+ RNS.log(f"HTTP server error for {self}: {e}", RNS.LOG_ERROR)
+ if RNS.Reticulum.panic_on_interface_error:
+ RNS.panic()
+
+ self._server_thread = threading.Thread(target=run_server, daemon=True)
+ self._server_thread.start()
+ self._start_receive_loop()
+ self.online = True
+ RNS.log(
+ f"HTTP/1.1 server started on http://{self.listen_host}:{self.listen_port}",
+ RNS.LOG_NOTICE,
+ )
+
+ def setup_server_asgi(self):
+ from hypercorn.asyncio import serve
+ from hypercorn.config import Config
+
+ config = Config()
+ bind = f"{self.listen_host}:{self.listen_port}"
+ config.bind = [bind]
+ config.certfile = self.tls_certfile
+ config.keyfile = self.tls_keyfile
+ config.alpn_protocols = ["h2", "http/1.1"]
+ if self.http_version == 3:
+ config.quic_bind = [bind]
+ config.alpn_protocols = ["h3", "h2", "http/1.1"]
+
+ app = self._build_asgi_app()
+ self._asgi_shutdown = asyncio.Event()
+
+ def run_server():
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ self._asgi_loop = loop
+ try:
+ loop.run_until_complete(
+ serve(app, config, shutdown_trigger=self._asgi_shutdown.wait),
+ )
+ except Exception as e:
+ if not self._stop_event.is_set():
+ RNS.log(f"ASGI HTTP server error for {self}: {e}", RNS.LOG_ERROR)
+ if RNS.Reticulum.panic_on_interface_error:
+ RNS.panic()
+ finally:
+ loop.close()
+
+ self._server_thread = threading.Thread(target=run_server, daemon=True)
+ self._server_thread.start()
+ self._start_receive_loop()
+ self.online = True
+ proto = f"HTTP/{self.http_version}"
+ RNS.log(
+ f"{proto} server started on https://{self.listen_host}:{self.listen_port}",
+ RNS.LOG_NOTICE,
+ )
+
+ def _start_receive_loop(self):
+ thread = threading.Thread(target=self.receive_loop, daemon=True)
+ thread.start()
+
+ def setup_client(self):
+ self._consecutive_failures = 0
+ self._max_backoff = 30.0
+
+ if self.http_version == 3:
+ self._h3_session = _H3Session(
+ self.server_url,
+ self.user_agent,
+ verify=self.tls_verify,
+ ca_certs=self.tls_ca_certs,
+ )
+ self._last_http_version = "HTTP/3"
+ else:
+ verify = self.tls_ca_certs if self.tls_ca_certs else self.tls_verify
+ limits = httpx.Limits(
+ max_connections=max(self.pool_maxsize, 1),
+ max_keepalive_connections=max(self.pool_maxsize, 1),
+ keepalive_expiry=float(self.keepalive_timeout),
+ )
+ self.session = httpx.Client(
+ http2=(self.http_version == 2),
+ verify=verify,
+ headers={
+ "User-Agent": self.user_agent,
+ "Accept-Encoding": "identity",
+ },
+ limits=limits,
+ timeout=httpx.Timeout(5.0),
+ )
+
+ thread = threading.Thread(target=self.client_loop, daemon=True)
+ thread.start()
+ self.online = True
+ RNS.log(
+ f"HTTP/{self.http_version} client started, connecting to {self.server_url}",
+ RNS.LOG_NOTICE,
+ )
+
+ def receive_loop(self):
+ while not self._stop_event.is_set():
+ try:
+ received_data = self._recv_queue.get(timeout=1)
+ if received_data:
+ self.process_incoming(received_data)
+ except Empty:
+ continue
+ except Exception as e:
+ if not self._stop_event.is_set():
+ RNS.log(f"Error in receive loop for {self}: {e}", RNS.LOG_ERROR)
+
+ def _client_exchange(self, data_to_send):
+ if self.http_version == 3:
+ body = self._h3_session.post(data_to_send, timeout=5.0)
+ self._client_requests += 1
+ self._last_http_version = "HTTP/3"
+ return body
+
+ response = self.session.post(self.server_url, content=data_to_send)
+ response.raise_for_status()
+ self._client_requests += 1
+ self._last_http_version = response.http_version
+ return response.content
+
+ def client_loop(self):
+ while not self._stop_event.is_set():
+ data_to_send = self._drain_send_frames()
+
+ try:
+ RNS.log(
+ f"Sending {len(data_to_send)} framed bytes to server",
+ RNS.LOG_EXTREME,
+ )
+ content = self._client_exchange(data_to_send)
+
+ if content:
+ RNS.log(
+ f"Received {len(content)} bytes from server",
+ RNS.LOG_EXTREME,
+ )
+ self._ingest_wire_bytes(content)
+
+ while not self._recv_queue.empty():
+ try:
+ packet = self._recv_queue.get_nowait()
+ except Empty:
+ break
+ if packet:
+ self.process_incoming(packet)
+
+ if self._consecutive_failures > 0:
+ RNS.log(f"Reconnected to server for {self}", RNS.LOG_INFO)
+ self._consecutive_failures = 0
+
+ except Exception as e:
+ if self._stop_event.is_set():
+ break
+ self._consecutive_failures += 1
+ if self._consecutive_failures % 10 == 1:
+ RNS.log(
+ f"Error communicating with server for {self} "
+ f"(attempt {self._consecutive_failures}): {e}",
+ RNS.LOG_WARNING,
+ )
+
+ if self._stop_event.is_set():
+ break
+
+ if self._consecutive_failures > 0:
+ delay = min(
+ self.poll_interval * (2 ** min(self._consecutive_failures - 1, 5)),
+ self._max_backoff,
+ )
+ else:
+ delay = self.poll_interval
+
+ self._stop_event.wait(delay)
+
+ def process_incoming(self, data):
+ if len(data) > 0 and self.online:
+ self.rxb += len(data)
+ self.owner.inbound(data, self)
+
+ def process_outgoing(self, data):
+ if self.online:
+ if len(data) > self.mtu:
+ RNS.log(
+ f"Payload too large ({len(data)} > {self.mtu}) for {self}",
+ RNS.LOG_ERROR,
+ )
+ return
+
+ self._send_queue.put(data)
+ self.txb += len(data)
+
+ def detach(self):
+ RNS.log(f"Detaching {self}", RNS.LOG_DEBUG)
+ self._stop_event.set()
+ self.online = False
+
+ if self.mode == "client":
+ if self.session is not None:
+ try:
+ self.session.close()
+ except Exception as e:
+ RNS.log(
+ f"Error closing HTTP session for {self}: {e}",
+ RNS.LOG_DEBUG,
+ )
+ if self._h3_session is not None:
+ try:
+ self._h3_session.close()
+ except Exception as e:
+ RNS.log(
+ f"Error closing HTTP/3 session for {self}: {e}",
+ RNS.LOG_DEBUG,
+ )
+
+ if self.mode == "server":
+ if self.http_version == 1:
+ httpd = getattr(self, "_http_server", None)
+ if httpd is not None:
+
+ def _shutdown():
+ try:
+ httpd.shutdown()
+ except Exception:
+ pass
+ try:
+ httpd.server_close()
+ except Exception:
+ pass
+
+ threading.Thread(target=_shutdown, daemon=True).start()
+
+ if hasattr(self, "_server_thread") and self._server_thread:
+ self._server_thread.join(timeout=2)
+ if self._server_thread.is_alive() and httpd is not None:
+ try:
+ httpd.socket.close()
+ except Exception:
+ pass
+ self._server_thread.join(timeout=1)
+ else:
+ if self._asgi_loop is not None and self._asgi_shutdown is not None:
+ self._asgi_loop.call_soon_threadsafe(self._asgi_shutdown.set)
+ if hasattr(self, "_server_thread") and self._server_thread:
+ self._server_thread.join(timeout=5)
+
+ def should_ingress_limit(self):
+ return False
+
+ def __str__(self):
+ name = getattr(self, "name", "?")
+ ver = getattr(self, "http_version", "?")
+ if self.mode == "server":
+ lh = getattr(self, "listen_host", "?")
+ lp = getattr(self, "listen_port", "?")
+ return f"HTTPTunnelInterface[{name}/HTTP{ver}/server/{lh}:{lp}]"
+ if self.mode == "client" and getattr(self, "server_url", None) is not None:
+ return f"HTTPTunnelInterface[{name}/HTTP{ver}/client/{self.server_url}]"
+ return f"HTTPTunnelInterface[{name}/HTTP{ver}/{self.mode}]"
+
+
+interface_class = HTTPTunnelInterface

diff --git a/meshchatx/src/backend/http/routes/app_info.py b/meshchatx/src/backend/http/routes/app_info.py
index d61592ae..9716055a 100644
--- a/meshchatx/src/backend/http/routes/app_info.py
+++ b/meshchatx/src/backend/http/routes/app_info.py
@@ -391,6 +391,7 @@ def register_app_info_routes(routes, app):
{
"app_info": {
"version": app.get_app_version(),
+ **(app.get_build_meta() if hasattr(app, "get_build_meta") else {}),
"lxmf_version": LXMF.__version__,
"rns_version": RNS.__version__,
"lxst_version": app.get_lxst_version(),

diff --git a/meshchatx/src/backend/http/routes/interfaces.py b/meshchatx/src/backend/http/routes/interfaces.py
index 3d18c4ec..8b9a6f42 100644
--- a/meshchatx/src/backend/http/routes/interfaces.py
+++ b/meshchatx/src/backend/http/routes/interfaces.py
@@ -1517,6 +1517,90 @@ def register_interfaces_routes(routes, app):
interface_details["command"] = interface_command
interface_details["respawn_delay"] = interface_respawn_delay
+ # HTTP tunnel (vendored RNS-over-HTTP). Config mode is client|server,
+ # which is distinct from Reticulum interface modes (full/gateway/...).
+ if interface_type == "HTTPInterface":
+ tunnel_mode = str(data.get("mode") or "").strip().lower()
+ if tunnel_mode not in {"client", "server"}:
+ return web.json_response(
+ {
+ "message": "HTTPInterface mode must be client or server",
+ },
+ status=422,
+ )
+ interface_details["mode"] = tunnel_mode
+
+ http_version_raw = data.get("http_version")
+ if http_version_raw not in (None, ""):
+ try:
+ http_version = int(http_version_raw)
+ except (TypeError, ValueError):
+ return web.json_response(
+ {"message": "http_version must be 1, 2, or 3"},
+ status=422,
+ )
+ if http_version not in (1, 2, 3):
+ return web.json_response(
+ {"message": "http_version must be 1, 2, or 3"},
+ status=422,
+ )
+ interface_details["http_version"] = http_version
+ else:
+ interface_details.pop("http_version", None)
+
+ if tunnel_mode == "client":
+ server_url = data.get("server_url")
+ if server_url is None or str(server_url).strip() == "":
+ return web.json_response(
+ {
+ "message": "server_url is required for HTTPInterface client mode"
+ },
+ status=422,
+ )
+ interface_details["server_url"] = str(server_url).strip()
+ InterfaceEditor.update_value(interface_details, data, "poll_interval")
+ for key in (
+ "listen_host",
+ "listen_port",
+ "check_user_agent",
+ "serve_html_page",
+ "html_file_path",
+ "tls_certfile",
+ "tls_keyfile",
+ ):
+ interface_details.pop(key, None)
+ else:
+ listen_host = data.get("listen_host")
+ if listen_host is None or str(listen_host).strip() == "":
+ listen_host = "0.0.0.0"
+ listen_port = data.get("listen_port")
+ if listen_port is None or listen_port == "":
+ return web.json_response(
+ {
+ "message": "listen_port is required for HTTPInterface server mode"
+ },
+ status=422,
+ )
+ interface_details["listen_host"] = str(listen_host).strip()
+ interface_details["listen_port"] = listen_port
+ InterfaceEditor.update_value(
+ interface_details, data, "check_user_agent"
+ )
+ InterfaceEditor.update_value(interface_details, data, "serve_html_page")
+ InterfaceEditor.update_value(interface_details, data, "html_file_path")
+ InterfaceEditor.update_value(interface_details, data, "tls_certfile")
+ InterfaceEditor.update_value(interface_details, data, "tls_keyfile")
+ for key in ("server_url", "poll_interval"):
+ interface_details.pop(key, None)
+
+ InterfaceEditor.update_value(interface_details, data, "mtu")
+ InterfaceEditor.update_value(interface_details, data, "user_agent")
+ InterfaceEditor.update_value(interface_details, data, "pool_connections")
+ InterfaceEditor.update_value(interface_details, data, "pool_maxsize")
+ InterfaceEditor.update_value(interface_details, data, "keepalive_timeout")
+ InterfaceEditor.update_value(interface_details, data, "tls_verify")
+ InterfaceEditor.update_value(interface_details, data, "tls_ca_certs")
+
_builtin_interface_types = frozenset(
{
"AutoInterface",
@@ -1532,6 +1616,7 @@ def register_interfaces_routes(routes, app):
"KISSInterface",
"AX25KISSInterface",
"PipeInterface",
+ "HTTPInterface",
},
)
if interface_type not in _builtin_interface_types:
@@ -1601,14 +1686,15 @@ def register_interfaces_routes(routes, app):
# set common interface options
InterfaceEditor.update_value(interface_details, data, "bitrate")
- mode_error = InterfaceEditor.apply_interface_mode(interface_details, data)
- if mode_error is not None:
- return web.json_response(
- {
- "message": mode_error,
- },
- status=422,
- )
+ if interface_type != "HTTPInterface":
+ mode_error = InterfaceEditor.apply_interface_mode(interface_details, data)
+ if mode_error is not None:
+ return web.json_response(
+ {
+ "message": mode_error,
+ },
+ status=422,
+ )
recursive_prs_error = InterfaceEditor.apply_yes_no_option(
interface_details,
data,

diff --git a/meshchatx/src/backend/interface_module_store.py b/meshchatx/src/backend/interface_module_store.py
index fad9bb46..c88df6cc 100644
--- a/meshchatx/src/backend/interface_module_store.py
+++ b/meshchatx/src/backend/interface_module_store.py
@@ -11,6 +11,7 @@ import tempfile
_MODULE_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_MAX_MODULE_BYTES = 512 * 1024
+_BUNDLED_INTERFACE_MODULES = ("HTTPInterface.py",)
def interface_modules_dir(reticulum_config_dir: str | None) -> str:
@@ -21,6 +22,82 @@ def interface_modules_dir(reticulum_config_dir: str | None) -> str:
return os.path.join(root, "interfaces")
+def bundled_interface_modules_dir() -> str:
+ """Return the package-data directory of MeshChatX-shipped interface modules."""
+ return os.path.join(os.path.dirname(__file__), "data", "interfaces")
+
+
+def resolve_bundled_interface_module_path(filename: str) -> str | None:
+ """Return an absolute path to a bundled TypeName.py, or None when missing."""
+ stem_name = os.path.basename(str(filename or ""))
+ if not stem_name.endswith(".py"):
+ return None
+ packaged = os.path.join(bundled_interface_modules_dir(), stem_name)
+ if os.path.isfile(packaged):
+ return packaged
+ # Dev checkout: vendor/rns_over_http next to the repo root.
+ repo_vendor = os.path.abspath(
+ os.path.join(
+ os.path.dirname(__file__),
+ "..",
+ "..",
+ "..",
+ "..",
+ "vendor",
+ "rns_over_http",
+ stem_name,
+ ),
+ )
+ if os.path.isfile(repo_vendor):
+ return repo_vendor
+ return None
+
+
+def ensure_bundled_interface_modules(reticulum_config_dir: str | None) -> list[dict]:
+ """Install or refresh MeshChatX-bundled interface modules into interfacepath.
+
+ Returns a list of install result dicts for modules that were written.
+ Missing bundled sources are skipped. Identical files are left untouched.
+ """
+ written: list[dict] = []
+ if not reticulum_config_dir:
+ return written
+ for filename in _BUNDLED_INTERFACE_MODULES:
+ source_path = resolve_bundled_interface_module_path(filename)
+ if source_path is None:
+ continue
+ try:
+ with open(source_path, "rb") as handle:
+ data = handle.read()
+ except OSError:
+ continue
+ stem = sanitize_interface_module_stem(filename)
+ if stem is None:
+ continue
+ target_dir = interface_modules_dir(reticulum_config_dir)
+ target_path = os.path.join(target_dir, f"{stem}.py")
+ if os.path.isfile(target_path):
+ try:
+ with open(target_path, "rb") as handle:
+ existing = handle.read()
+ except OSError:
+ existing = None
+ if existing == data:
+ continue
+ try:
+ written.append(
+ install_interface_module(
+ reticulum_config_dir,
+ filename=filename,
+ data=data,
+ overwrite=True,
+ ),
+ )
+ except ValueError:
+ continue
+ return written
+
+
def sanitize_interface_module_stem(name: str | None) -> str | None:
"""Return a safe TypeName stem, or None when the name is invalid."""
if not name or not isinstance(name, str):

diff --git a/meshchatx/src/build_meta.py b/meshchatx/src/build_meta.py
new file mode 100644
index 00000000..1d9cd22d
--- /dev/null
+++ b/meshchatx/src/build_meta.py
@@ -0,0 +1,52 @@
+# SPDX-License-Identifier: 0BSD
+"""Baked build metadata (git commit and channel).
+
+Defaults keep source checkouts and tests working without a bake step.
+Builds run scripts/bake_build_meta.js which writes _build_meta_baked.py
+(gitignored). Packaging includes that overlay when present.
+"""
+
+from __future__ import annotations
+
+GIT_COMMIT = ""
+GIT_COMMIT_SHORT = ""
+BUILD_CHANNEL = "local"
+IS_DEV_BUILD = False
+
+try:
+ from meshchatx.src._build_meta_baked import ( # type: ignore[import-not-found]
+ BUILD_CHANNEL as _BAKED_CHANNEL,
+ GIT_COMMIT as _BAKED_COMMIT,
+ GIT_COMMIT_SHORT as _BAKED_SHORT,
+ IS_DEV_BUILD as _BAKED_IS_DEV,
+ )
+
+ GIT_COMMIT = str(_BAKED_COMMIT or "")
+ GIT_COMMIT_SHORT = str(_BAKED_SHORT or "")
+ BUILD_CHANNEL = str(_BAKED_CHANNEL or "local")
+ IS_DEV_BUILD = bool(_BAKED_IS_DEV)
+except Exception:
+ pass
+
+
+def display_version(base_version: str) -> str:
+ """Version string for UI labels (adds -dev for nightly/local builds)."""
+ version = str(base_version or "").strip() or "unknown"
+ if IS_DEV_BUILD and not version.endswith("-dev"):
+ return f"{version}-dev"
+ return version
+
+
+def as_dict(base_version: str) -> dict[str, object]:
+ """Fields merged into /api/v1/app/info."""
+ short = (GIT_COMMIT_SHORT or "").strip()
+ full = (GIT_COMMIT or "").strip()
+ if not short and full:
+ short = full[:7]
+ return {
+ "git_commit": full or short,
+ "git_commit_short": short,
+ "build_channel": BUILD_CHANNEL or "local",
+ "is_dev_build": bool(IS_DEV_BUILD),
+ "display_version": display_version(base_version),
+ }

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 05a8895a..96eed2b0 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -466,14 +466,14 @@
class="flex items-center py-2 text-[10px] font-mono text-gray-500 transition-colors hover:text-gray-700 dark:text-zinc-500 dark:hover:text-zinc-300"
:class="isSidebarCollapsed ? 'justify-center px-0' : 'justify-start px-3'"
data-testid="sidebar-app-version"
- :title="$t('about.version', { version: appInfo.version })"
+ :title="sidebarVersionTitle"
>
<MaterialDesignIcon
v-if="isSidebarCollapsed"
icon-name="information-outline"
class="size-4"
/>
- <span v-else>{{ $t("about.version", { version: appInfo.version }) }}</span>
+ <span v-else>{{ sidebarVersionLabel }}</span>
</RouterLink>
</div>
</div>
@@ -747,6 +747,34 @@ export default {
isPopoutMode() {
return this.currentPopoutType != null;
},
+ sidebarDisplayVersion() {
+ const info = this.appInfo || {};
+ if (info.display_version) {
+ return info.display_version;
+ }
+ const base = info.version || "";
+ if (info.is_dev_build && base && !String(base).endsWith("-dev")) {
+ return `${base}-dev`;
+ }
+ return base;
+ },
+ sidebarVersionLabel() {
+ const version = this.sidebarDisplayVersion;
+ if (!version) {
+ return "";
+ }
+ const label = this.$t("about.version", { version });
+ const short =
+ this.appInfo?.git_commit_short ||
+ (this.appInfo?.git_commit ? String(this.appInfo.git_commit).slice(0, 7) : "");
+ if (this.appInfo?.is_dev_build && short) {
+ return `${label} ${short}`;
+ }
+ return label;
+ },
+ sidebarVersionTitle() {
+ return this.sidebarVersionLabel;
+ },
unreadConversationsCount() {
return GlobalState.unreadConversationsCount;
},

diff --git a/meshchatx/src/frontend/components/TutorialModal.vue b/meshchatx/src/frontend/components/TutorialModal.vue
index 9fd31653..ec91a4ce 100644
--- a/meshchatx/src/frontend/components/TutorialModal.vue
+++ b/meshchatx/src/frontend/components/TutorialModal.vue
@@ -15,22 +15,6 @@
<v-card
class="flex min-h-0 flex-1 flex-col bg-white dark:bg-zinc-950 border-0 overflow-hidden relative h-full max-h-dvh"
>
- <!-- Settings Controls -->
- <div class="absolute top-4 left-4 z-50 flex items-center gap-1">
- <LanguageSelector @language-change="onLanguageChange" />
- <button
- type="button"
- class="rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
- :title="config?.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
- @click="toggleTheme"
- >
- <MaterialDesignIcon
- :icon-name="config?.theme === 'dark' ? 'brightness-6' : 'brightness-4'"
- class="w-5 h-5 sm:w-6 sm:h-6"
- />
- </button>
- </div>
-
<!-- Progress Bar -->
<div class="w-full h-1.5 bg-gray-100 dark:bg-zinc-900 overflow-hidden flex">
<div
@@ -45,9 +29,25 @@
></div>
</div>
+ <!-- Language / theme (in-flow so titles are not covered) -->
+ <div class="shrink-0 flex items-center justify-end gap-1 px-3 py-1.5 sm:px-4">
+ <LanguageSelector @language-change="onLanguageChange" />
+ <button
+ type="button"
+ class="rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
+ :title="config?.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
+ @click="toggleTheme"
+ >
+ <MaterialDesignIcon
+ :icon-name="config?.theme === 'dark' ? 'brightness-6' : 'brightness-4'"
+ class="w-5 h-5 sm:w-6 sm:h-6"
+ />
+ </button>
+ </div>
+
<!-- Content Area -->
<v-card-text
- class="relative min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-6 sm:px-6 md:px-12 md:py-10"
+ class="relative min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6 sm:py-6 md:px-12 md:py-10"
>
<transition name="fade-slide" mode="out-in">
<!-- Step 1: Welcome -->
@@ -388,16 +388,19 @@
</p>
</div>
- <div class="grid grid-cols-1 gap-4">
+ <div
+ class="grid grid-cols-1 gap-4"
+ :class="{ 'pointer-events-none opacity-70': connectionSetupBusy }"
+ >
<button
type="button"
- class="text-left flex items-start gap-4 p-5 rounded-2xl bg-indigo-500/5 dark:bg-indigo-500/10 border-2 transition-all"
+ class="text-left flex items-start gap-4 p-5 rounded-2xl bg-indigo-500/5 dark:bg-indigo-500/10 border-2 transition-all disabled:cursor-not-allowed"
:class="[
connectionMode === 'recommended'
? 'border-indigo-500 ring-2 ring-indigo-500/30'
: 'border-indigo-500/20 hover:border-indigo-500',
]"
- :disabled="addingRecommended || savingDiscovery || addingLocal || reloadingReticulum"
+ :disabled="connectionSetupBusy"
@click="useRecommendedMode"
>
<v-icon icon="mdi-access-point-network" color="indigo" size="40"></v-icon>
@@ -419,13 +422,13 @@
<button
type="button"
- class="text-left flex items-start gap-4 p-5 rounded-2xl bg-blue-500/5 dark:bg-blue-500/10 border-2 transition-all"
+ class="text-left flex items-start gap-4 p-5 rounded-2xl bg-blue-500/5 dark:bg-blue-500/10 border-2 transition-all disabled:cursor-not-allowed"
:class="[
connectionMode === 'discovery'
? 'border-blue-500 ring-2 ring-blue-500/30'
: 'border-blue-500/20 hover:border-blue-500',
]"
- :disabled="savingDiscovery || addingRecommended"
+ :disabled="connectionSetupBusy"
@click="useDiscoveryMode"
>
<v-icon icon="mdi-radar" color="blue" size="40"></v-icon>
@@ -447,13 +450,13 @@
<button
type="button"
- class="text-left flex items-start gap-4 p-5 rounded-2xl bg-emerald-500/5 dark:bg-emerald-500/10 border-2 transition-all"
+ class="text-left flex items-start gap-4 p-5 rounded-2xl bg-emerald-500/5 dark:bg-emerald-500/10 border-2 transition-all disabled:cursor-not-allowed"
:class="[
connectionMode === 'local'
? 'border-emerald-500 ring-2 ring-emerald-500/30'
: 'border-emerald-500/20 hover:border-emerald-500',
]"
- :disabled="addingLocal || addingRecommended || reloadingReticulum"
+ :disabled="connectionSetupBusy"
@click="useLocalMode"
>
<v-icon icon="mdi-lan" color="emerald" size="40"></v-icon>
@@ -475,12 +478,13 @@
<button
type="button"
- class="text-left flex items-start gap-4 p-5 rounded-2xl bg-gray-100/50 dark:bg-zinc-800/40 border-2 transition-all"
+ class="text-left flex items-start gap-4 p-5 rounded-2xl bg-gray-100/50 dark:bg-zinc-800/40 border-2 transition-all disabled:cursor-not-allowed"
:class="[
connectionMode === 'manual'
? 'border-gray-500 ring-2 ring-gray-500/30'
: 'border-gray-300 dark:border-zinc-700 hover:border-gray-500',
]"
+ :disabled="connectionSetupBusy"
@click="useManualMode"
>
<v-icon icon="mdi-cog-outline" color="gray" size="40"></v-icon>
@@ -513,7 +517,7 @@
<button
type="button"
class="inline-flex items-center gap-2 rounded-xl border border-blue-500/30 bg-blue-500/10 px-4 py-2 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-500/15 dark:text-blue-300 dark:hover:bg-blue-500/20 disabled:opacity-60"
- :disabled="loadingInterfaces || loadingDiscovered || pickingRandomBootstraps"
+ :disabled="bootstrapPickBusy"
@click="pickRandomTcpBootstraps"
>
<v-progress-circular
@@ -774,6 +778,7 @@
<button
type="button"
class="tutorial-action-btn tutorial-action-btn-secondary"
+ :disabled="bootstrapActionBusy"
@click="skipBootstraps"
>
{{ $t("tutorial.bootstrap_skip") }}
@@ -781,13 +786,11 @@
<button
type="button"
class="tutorial-action-btn tutorial-action-btn-success"
- :disabled="
- addingBootstraps || reloadingReticulum || selectedBootstrapCount === 0
- "
+ :disabled="bootstrapActionBusy || selectedBootstrapCount === 0"
@click="confirmBootstraps"
>
<v-progress-circular
- v-if="addingBootstraps || reloadingReticulum"
+ v-if="bootstrapActionBusy"
indeterminate
size="14"
width="2"
@@ -1111,6 +1114,7 @@
v-if="currentStep > 1 && currentStep < totalSteps"
type="button"
class="tutorial-action-btn tutorial-action-btn-secondary"
+ :disabled="tutorialNavBusy"
@click="previousStep"
>
{{ $t("tutorial.back") }}
@@ -1122,6 +1126,7 @@
v-if="currentStep < totalSteps"
type="button"
class="tutorial-action-btn tutorial-action-btn-secondary"
+ :disabled="tutorialNavBusy"
@click="skipTutorial"
>
{{ $t("tutorial.skip") }}
@@ -1132,7 +1137,7 @@
type="button"
class="tutorial-action-btn tutorial-action-btn-primary"
:disabled="
- (currentStep === 2 && identityImportInProgress) ||
+ tutorialNavBusy ||
(currentStep === 2 && identityMode === 'import' && !hasIdentityImportInput)
"
@click="handlePrimaryAction"
@@ -1141,10 +1146,10 @@
</button>
<button
- v-else
+ v-else-if="currentStep === totalSteps"
type="button"
class="tutorial-action-btn tutorial-action-btn-success"
- :disabled="finishingTutorial"
+ :disabled="finishingTutorial || tutorialNavBusy"
@click="finishTutorial"
>
{{ $t("tutorial.finish_setup") }}
@@ -1155,22 +1160,6 @@
</v-dialog>
<div v-else class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden relative">
- <!-- Settings Controls -->
- <div class="absolute top-4 left-4 z-50 flex items-center gap-1">
- <LanguageSelector @language-change="onLanguageChange" />
- <button
- type="button"
- class="rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
- :title="config?.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
- @click="toggleTheme"
- >
- <MaterialDesignIcon
- :icon-name="config?.theme === 'dark' ? 'brightness-6' : 'brightness-4'"
- class="w-5 h-5 sm:w-6 sm:h-6"
- />
- </button>
- </div>
-
<!-- Progress Bar -->
<div class="w-full h-1.5 bg-gray-100 dark:bg-zinc-900 overflow-hidden flex">
<div
@@ -1185,7 +1174,23 @@
></div>
</div>
- <div class="flex-1 overflow-y-auto px-6 md:px-12 py-10">
+ <!-- Language / theme (in-flow so titles are not covered) -->
+ <div class="shrink-0 flex items-center justify-end gap-1 px-3 py-1.5 sm:px-4">
+ <LanguageSelector @language-change="onLanguageChange" />
+ <button
+ type="button"
+ class="rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
+ :title="config?.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
+ @click="toggleTheme"
+ >
+ <MaterialDesignIcon
+ :icon-name="config?.theme === 'dark' ? 'brightness-6' : 'brightness-4'"
+ class="w-5 h-5 sm:w-6 sm:h-6"
+ />
+ </button>
+ </div>
+
+ <div class="flex-1 overflow-y-auto px-6 md:px-12 py-6 md:py-10">
<div class="w-full h-full flex flex-col justify-between">
<transition name="fade-slide" mode="out-in">
<!-- Step 1: Welcome -->
@@ -1536,16 +1541,19 @@
</p>
</div>
- <div class="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-6xl mx-auto">
+ <div
+ class="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-6xl mx-auto"
+ :class="{ 'pointer-events-none opacity-70': connectionSetupBusy }"
+ >
<button
type="button"
- class="text-left flex flex-col gap-4 p-8 rounded-3xl bg-indigo-500/5 dark:bg-indigo-500/10 border-2 transition-all hover:scale-[1.02]"
+ class="text-left flex flex-col gap-4 p-8 rounded-3xl bg-indigo-500/5 dark:bg-indigo-500/10 border-2 transition-all hover:scale-[1.02] disabled:cursor-not-allowed disabled:hover:scale-100"
:class="[
connectionMode === 'recommended'
? 'border-indigo-500 ring-2 ring-indigo-500/30'
: 'border-indigo-500/20 hover:border-indigo-500',
]"
- :disabled="addingRecommended || savingDiscovery || addingLocal || reloadingReticulum"
+ :disabled="connectionSetupBusy"
@click="useRecommendedMode"
>
<v-icon icon="mdi-access-point-network" color="indigo" size="56"></v-icon>
@@ -1565,13 +1573,13 @@
<button
type="button"
- class="text-left flex flex-col gap-4 p-8 rounded-3xl bg-blue-500/5 dark:bg-blue-500/10 border-2 transition-all hover:scale-[1.02]"
+ class="text-left flex flex-col gap-4 p-8 rounded-3xl bg-blue-500/5 dark:bg-blue-500/10 border-2 transition-all hover:scale-[1.02] disabled:cursor-not-allowed disabled:hover:scale-100"
:class="[
connectionMode === 'discovery'
? 'border-blue-500 ring-2 ring-blue-500/30'
: 'border-blue-500/20 hover:border-blue-500',
]"
- :disabled="savingDiscovery || addingRecommended"
+ :disabled="connectionSetupBusy"
@click="useDiscoveryMode"
>
<v-icon icon="mdi-radar" color="blue" size="56"></v-icon>
@@ -1591,13 +1599,13 @@
<button
type="button"
- class="text-left flex flex-col gap-4 p-8 rounded-3xl bg-emerald-500/5 dark:bg-emerald-500/10 border-2 transition-all hover:scale-[1.02]"
+ class="text-left flex flex-col gap-4 p-8 rounded-3xl bg-emerald-500/5 dark:bg-emerald-500/10 border-2 transition-all hover:scale-[1.02] disabled:cursor-not-allowed disabled:hover:scale-100"
:class="[
connectionMode === 'local'
? 'border-emerald-500 ring-2 ring-emerald-500/30'
: 'border-emerald-500/20 hover:border-emerald-500',
]"
- :disabled="addingLocal || addingRecommended || reloadingReticulum"
+ :disabled="connectionSetupBusy"
@click="useLocalMode"
>
<v-icon icon="mdi-lan" color="emerald" size="56"></v-icon>
@@ -1617,12 +1625,13 @@
<button
type="button"
- class="text-left flex flex-col gap-4 p-8 rounded-3xl bg-gray-100/50 dark:bg-zinc-800/40 border-2 transition-all hover:scale-[1.02]"
+ class="text-left flex flex-col gap-4 p-8 rounded-3xl bg-gray-100/50 dark:bg-zinc-800/40 border-2 transition-all hover:scale-[1.02] disabled:cursor-not-allowed disabled:hover:scale-100"
:class="[
connectionMode === 'manual'
? 'border-gray-500 ring-2 ring-gray-500/30'
: 'border-gray-300 dark:border-zinc-700 hover:border-gray-500',
]"
+ :disabled="connectionSetupBusy"
@click="useManualMode"
>
<v-icon icon="mdi-cog-outline" color="gray" size="56"></v-icon>
@@ -1653,7 +1662,7 @@
<button
type="button"
class="inline-flex items-center gap-2 rounded-xl border-2 border-blue-500/30 bg-blue-500/10 px-5 py-2.5 text-sm font-semibold text-blue-700 transition-colors hover:bg-blue-500/15 dark:text-blue-300 dark:hover:bg-blue-500/20 disabled:opacity-60"
- :disabled="loadingInterfaces || loadingDiscovered || pickingRandomBootstraps"
+ :disabled="bootstrapPickBusy"
@click="pickRandomTcpBootstraps"
>
<v-progress-circular
@@ -1922,6 +1931,7 @@
<button
type="button"
class="tutorial-action-btn tutorial-action-btn-secondary"
+ :disabled="bootstrapActionBusy"
@click="skipBootstraps"
>
{{ $t("tutorial.bootstrap_skip") }}
@@ -1929,11 +1939,11 @@
<button
type="button"
class="tutorial-action-btn tutorial-action-btn-success"
- :disabled="addingBootstraps || reloadingReticulum || selectedBootstrapCount === 0"
+ :disabled="bootstrapActionBusy || selectedBootstrapCount === 0"
@click="confirmBootstraps"
>
<v-progress-circular
- v-if="addingBootstraps || reloadingReticulum"
+ v-if="bootstrapActionBusy"
indeterminate
size="16"
width="2"
@@ -2300,6 +2310,7 @@
v-if="currentStep > 1 && currentStep < totalSteps"
type="button"
class="tutorial-action-btn tutorial-action-btn-secondary"
+ :disabled="tutorialNavBusy"
@click="previousStep"
>
{{ $t("tutorial.back") }}
@@ -2311,6 +2322,7 @@
v-if="currentStep < totalSteps"
type="button"
class="tutorial-action-btn tutorial-action-btn-secondary"
+ :disabled="tutorialNavBusy"
@click="skipTutorial"
>
{{ $t("tutorial.skip_setup") }}
@@ -2321,7 +2333,7 @@
type="button"
class="tutorial-action-btn tutorial-action-btn-primary"
:disabled="
- (currentStep === 2 && identityImportInProgress) ||
+ tutorialNavBusy ||
(currentStep === 2 && identityMode === 'import' && !hasIdentityImportInput)
"
@click="handlePrimaryAction"
@@ -2330,10 +2342,10 @@
</button>
<button
- v-else
+ v-else-if="currentStep === totalSteps"
type="button"
class="tutorial-action-btn tutorial-action-btn-success"
- :disabled="finishingTutorial"
+ :disabled="finishingTutorial || tutorialNavBusy"
@click="finishTutorial"
>
{{ $t("tutorial.finish_setup") }}
@@ -2490,6 +2502,24 @@ export default {
}
return this.currentStep < this.totalSteps;
},
+ connectionSetupBusy() {
+ return this.addingRecommended || this.savingDiscovery || this.addingLocal || this.reloadingReticulum;
+ },
+ bootstrapPickBusy() {
+ return this.pickingRandomBootstraps || this.loadingInterfaces || this.loadingDiscovered;
+ },
+ bootstrapActionBusy() {
+ return this.pickingRandomBootstraps || this.addingBootstraps || this.reloadingReticulum;
+ },
+ tutorialNavBusy() {
+ return (
+ this.connectionSetupBusy ||
+ this.bootstrapActionBusy ||
+ this.savingPropagation ||
+ this.finishingTutorial ||
+ this.identityImportInProgress
+ );
+ },
},
watch: {
communityInterfaces() {
@@ -2841,7 +2871,7 @@ export default {
}
},
async useRecommendedMode() {
- if (this.addingRecommended) {
+ if (this.connectionSetupBusy) {
return;
}
this.addingRecommended = true;
@@ -2851,7 +2881,6 @@ export default {
type: "AutoInterface",
enabled: true,
});
- this.interfaceAddedViaTutorial = true;
GlobalState.hasPendingInterfaceChanges = true;
GlobalState.modifiedInterfaceNames.add("Local Network");
@@ -2874,6 +2903,7 @@ export default {
await this.loadDiscoveredInterfaces();
await this.pickRandomTcpBootstraps({ silent: true, auto: true, count: 3 });
this.bootstrapAutoPickDone = true;
+ this.interfaceAddedViaTutorial = true;
} catch (e) {
console.error("Failed to apply recommended connection mode:", e);
ToastUtils.error(
@@ -2886,6 +2916,9 @@ export default {
}
},
async useDiscoveryMode() {
+ if (this.connectionSetupBusy) {
+ return;
+ }
this.savingDiscovery = true;
try {
const payload = {
@@ -2901,6 +2934,7 @@ export default {
this.bootstrapListSearch = "";
this.bootstrapDiscoveredSectionOpen = true;
this.bootstrapCommunitySectionOpen = true;
+ this.bootstrapAutoPickDone = false;
await this.loadCommunityInterfaces();
await this.loadDiscoveredInterfaces();
await this.maybeAutoPickBootstrapTcp();
@@ -2912,7 +2946,9 @@ export default {
}
},
async useLocalMode() {
- if (this.addingLocal) return;
+ if (this.connectionSetupBusy) {
+ return;
+ }
this.addingLocal = true;
try {
await window.api.post("/api/v1/reticulum/interfaces/add", {
@@ -2938,6 +2974,9 @@ export default {
}
},
useManualMode() {
+ if (this.connectionSetupBusy) {
+ return;
+ }
this.connectionMode = "manual";
this.currentStep = 5;
},
@@ -2945,6 +2984,9 @@ export default {
return this.selectedBootstrapKeys.includes(key);
},
toggleBootstrap(key) {
+ if (this.bootstrapActionBusy) {
+ return;
+ }
const idx = this.selectedBootstrapKeys.indexOf(key);
if (idx >= 0) {
this.selectedBootstrapKeys.splice(idx, 1);
@@ -3067,17 +3109,12 @@ export default {
async pickRandomTcpBootstraps(options = {}) {
const silent = options.silent === true;
const auto = options.auto === true;
- if (!silent && !auto) {
- this.pickingRandomBootstraps = true;
+ if (this.pickingRandomBootstraps) {
+ return;
}
+ this.pickingRandomBootstraps = true;
+ // Yield once so the busy spinner can paint before selection work.
await Promise.resolve();
- await new Promise((resolve) => {
- if (typeof requestAnimationFrame !== "undefined") {
- requestAnimationFrame(() => resolve());
- } else {
- setTimeout(resolve, 0);
- }
- });
try {
let entries = this.pickEligibleCommunityTcpBootstrapForRandom();
entries = this.dedupeBootstrapEntries(entries);
@@ -3101,9 +3138,7 @@ export default {
);
}
} finally {
- if (!silent && !auto) {
- this.pickingRandomBootstraps = false;
- }
+ this.pickingRandomBootstraps = false;
}
},
async maybeAutoPickBootstrapTcp() {
@@ -3116,12 +3151,17 @@ export default {
if (this.selectedBootstrapKeys.length > 0) {
return;
}
+ if (this.pickingRandomBootstraps) {
+ return;
+ }
const entries = this.dedupeBootstrapEntries(this.pickEligibleCommunityTcpBootstrapForRandom());
if (entries.length === 0) {
return;
}
await this.pickRandomTcpBootstraps({ silent: true, auto: true });
- this.bootstrapAutoPickDone = true;
+ if (this.selectedBootstrapKeys.length > 0) {
+ this.bootstrapAutoPickDone = true;
+ }
},
buildBootstrapPayload(item) {
if (item.kind === "discovered") {
@@ -3182,7 +3222,9 @@ export default {
}
},
async confirmBootstraps() {
- if (this.addingBootstraps) return;
+ if (this.bootstrapActionBusy) {
+ return;
+ }
if (this.selectedBootstrapKeys.length === 0) {
ToastUtils.warning(this.$t("tutorial.bootstrap_pick_at_least_one"));
return;
@@ -3228,6 +3270,9 @@ export default {
}
},
skipBootstraps() {
+ if (this.bootstrapActionBusy) {
+ return;
+ }
this.currentStep = 5;
},
async enableAutoPropagation() {
@@ -3348,7 +3393,7 @@ export default {
ToastUtils.warning(this.$t("tutorial.connect_mode_required"));
return;
}
- if (this.connectionMode !== "discovery") {
+ if (this.connectionMode !== "discovery" && this.connectionMode !== "recommended") {
this.currentStep = 5;
return;
}
@@ -3365,14 +3410,24 @@ export default {
}
},
previousStep() {
+ if (this.tutorialNavBusy) {
+ return;
+ }
if (this.currentStep <= 1) return;
- if (this.currentStep === 5 && this.connectionMode !== "discovery") {
+ if (
+ this.currentStep === 5 &&
+ this.connectionMode !== "discovery" &&
+ this.connectionMode !== "recommended"
+ ) {
this.currentStep = 3;
return;
}
this.currentStep--;
},
async skipTutorial() {
+ if (this.tutorialNavBusy) {
+ return;
+ }
if (!(await DialogUtils.confirm(this.$t("tutorial.skip_confirm")))) {
return;
}
@@ -3434,7 +3489,7 @@ export default {
}
},
async finishTutorial() {
- if (this.finishingTutorial) {
+ if (this.finishingTutorial || this.tutorialNavBusy) {
return;
}
this.finishingTutorial = true;

diff --git a/meshchatx/src/frontend/components/about/AboutPage.vue b/meshchatx/src/frontend/components/about/AboutPage.vue
index c27c07a7..5f3e2c97 100644
--- a/meshchatx/src/frontend/components/about/AboutPage.vue
+++ b/meshchatx/src/frontend/components/about/AboutPage.vue
@@ -25,7 +25,18 @@
class="flex flex-col gap-0.5 sm:flex-row sm:flex-wrap sm:items-baseline sm:gap-x-3 sm:gap-y-0"
>
<div class="text-sm font-black uppercase tracking-[0.2em] text-blue-500 opacity-80">
- {{ $t("about.version", { version: appInfo.version }) }}
+ {{ $t("about.version", { version: aboutDisplayVersion }) }}
+ </div>
+ <div
+ v-if="appInfo.git_commit_short || appInfo.git_commit"
+ class="text-xs font-medium normal-case tracking-normal font-mono text-gray-500 dark:text-zinc-500"
+ :title="appInfo.git_commit || appInfo.git_commit_short"
+ >
+ {{
+ $t("about.git_commit", {
+ commit: appInfo.git_commit_short || appInfo.git_commit,
+ })
+ }}
</div>
<div
v-if="formattedUiBuildDate"
@@ -663,7 +674,7 @@
{{ $t("about.app_name") }}
</div>
<div class="text-xs font-mono font-bold text-gray-400">
- v{{ appInfo.version }}
+ v{{ aboutDisplayVersion }}
</div>
</div>
</div>
@@ -1274,6 +1285,17 @@ export default {
isElectron() {
return ElectronUtils.isElectron();
},
+ aboutDisplayVersion() {
+ const info = this.appInfo || {};
+ if (info.display_version) {
+ return info.display_version;
+ }
+ const base = info.version || "unknown";
+ if (info.is_dev_build && !String(base).endsWith("-dev")) {
+ return `${base}-dev`;
+ }
+ return base;
+ },
formattedUiBuildDate() {
try {
const raw = typeof __APP_BUILD_TIME__ !== "undefined" ? __APP_BUILD_TIME__ : "";

diff --git a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
index 31f5834a..3d25b5f6 100644
--- a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
+++ b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
@@ -120,6 +120,12 @@
icon: 'auto-fix',
color: 'text-pink-500',
},
+ {
+ id: 'HTTPInterface',
+ name: 'HTTP Tunnel',
+ icon: 'web',
+ color: 'text-teal-500',
+ },
]"
:key="type.id"
type="button"
@@ -1333,6 +1339,192 @@
</div>
</div>
+ <!-- HTTP tunnel (bundled RNS-over-HTTP) -->
+ <div v-if="newInterfaceType === 'HTTPInterface'" class="space-y-4">
+ <p class="text-xs text-gray-600 dark:text-zinc-400 leading-relaxed">
+ {{ $t("interfaces.http_tunnel_intro") }}
+ </p>
+ <div class="flex items-center gap-2">
+ <button
+ type="button"
+ class="flex-1 py-2 rounded-2xl border text-xs font-bold uppercase tracking-tight transition"
+ :class="
+ newInterfaceHttpTunnelMode === 'client'
+ ? 'bg-teal-500/10 border-teal-500 text-teal-700 dark:text-teal-300'
+ : 'bg-gray-50/50 dark:bg-zinc-800/30 border-gray-100 dark:border-zinc-800 text-gray-600 dark:text-zinc-400'
+ "
+ @click="newInterfaceHttpTunnelMode = 'client'"
+ >
+ {{ $t("interfaces.http_tunnel_mode_client") }}
+ </button>
+ <button
+ type="button"
+ class="flex-1 py-2 rounded-2xl border text-xs font-bold uppercase tracking-tight transition"
+ :class="
+ newInterfaceHttpTunnelMode === 'server'
+ ? 'bg-teal-500/10 border-teal-500 text-teal-700 dark:text-teal-300'
+ : 'bg-gray-50/50 dark:bg-zinc-800/30 border-gray-100 dark:border-zinc-800 text-gray-600 dark:text-zinc-400'
+ "
+ @click="newInterfaceHttpTunnelMode = 'server'"
+ >
+ {{ $t("interfaces.http_tunnel_mode_server") }}
+ </button>
+ </div>
+ <div v-if="newInterfaceHttpTunnelMode === 'client'" class="space-y-4">
+ <div>
+ <FormLabel class="glass-label">{{
+ $t("interfaces.http_tunnel_server_url")
+ }}</FormLabel>
+ <input
+ v-model="newInterfaceHttpServerUrl"
+ type="url"
+ placeholder="https://example.com:8080/"
+ class="input-field"
+ autocomplete="off"
+ spellcheck="false"
+ />
+ </div>
+ <div>
+ <FormLabel class="glass-label">{{
+ $t("interfaces.http_tunnel_poll_interval")
+ }}</FormLabel>
+ <input
+ v-model="newInterfaceHttpPollInterval"
+ type="number"
+ min="0.01"
+ step="0.01"
+ placeholder="0.1"
+ class="input-field"
+ />
+ </div>
+ </div>
+ <div v-else class="space-y-4">
+ <div class="grid grid-cols-2 gap-4">
+ <div>
+ <FormLabel class="glass-label">{{
+ $t("interfaces.http_tunnel_listen_host")
+ }}</FormLabel>
+ <input
+ v-model="newInterfaceHttpListenHost"
+ type="text"
+ placeholder="0.0.0.0"
+ class="input-field"
+ />
+ </div>
+ <div>
+ <FormLabel class="glass-label">{{
+ $t("interfaces.http_tunnel_listen_port")
+ }}</FormLabel>
+ <input
+ v-model="newInterfaceHttpListenPort"
+ type="number"
+ min="1"
+ max="65535"
+ placeholder="8080"
+ class="input-field"
+ />
+ </div>
+ </div>
+ <div class="flex items-center justify-between gap-4">
+ <div class="min-w-0">
+ <FormLabel class="glass-label mb-0!">{{
+ $t("interfaces.http_tunnel_check_user_agent")
+ }}</FormLabel>
+ <p class="text-xs text-gray-400 mt-1">
+ {{ $t("interfaces.http_tunnel_check_user_agent_hint") }}
+ </p>
+ </div>
+ <Toggle v-model="newInterfaceHttpCheckUserAgent" />
+ </div>
+ </div>
+ <div class="grid grid-cols-2 gap-4">
+ <div>
+ <FormLabel class="glass-label">{{
+ $t("interfaces.http_tunnel_http_version")
+ }}</FormLabel>
+ <select v-model="newInterfaceHttpVersion" class="input-field">
+ <option :value="1">HTTP/1.1</option>
+ <option :value="2">HTTP/2</option>
+ <option :value="3">HTTP/3</option>
+ </select>
+ <p class="mt-1 text-xs text-gray-500 dark:text-zinc-400">
+ {{ $t("interfaces.http_tunnel_http_version_hint") }}
+ </p>
+ </div>
+ <div>
+ <FormLabel class="glass-label">{{
+ $t("interfaces.http_tunnel_mtu")
+ }}</FormLabel>
+ <input
+ v-model="newInterfaceHttpMtu"
+ type="number"
+ min="128"
+ placeholder="4096"
+ class="input-field"
+ />
+ </div>
+ </div>
+ <div>
+ <FormLabel class="glass-label">{{
+ $t("interfaces.http_tunnel_user_agent")
+ }}</FormLabel>
+ <input
+ v-model="newInterfaceHttpUserAgent"
+ type="text"
+ placeholder="RNS-HTTP-Tunnel/1.0"
+ class="input-field"
+ autocomplete="off"
+ spellcheck="false"
+ />
+ </div>
+ <div
+ v-if="newInterfaceHttpVersion >= 2"
+ class="space-y-4 rounded-2xl border border-gray-100 dark:border-zinc-800 p-3"
+ >
+ <p class="text-xs text-gray-500 dark:text-zinc-400">
+ {{ $t("interfaces.http_tunnel_tls_hint") }}
+ </p>
+ <div
+ v-if="newInterfaceHttpTunnelMode === 'server'"
+ class="grid grid-cols-1 gap-3"
+ >
+ <div>
+ <FormLabel class="glass-label">tls_certfile</FormLabel>
+ <input
+ v-model="newInterfaceHttpTlsCertfile"
+ type="text"
+ class="input-field font-mono text-xs"
+ autocomplete="off"
+ />
+ </div>
+ <div>
+ <FormLabel class="glass-label">tls_keyfile</FormLabel>
+ <input
+ v-model="newInterfaceHttpTlsKeyfile"
+ type="text"
+ class="input-field font-mono text-xs"
+ autocomplete="off"
+ />
+ </div>
+ </div>
+ <div
+ v-if="newInterfaceHttpTunnelMode === 'client'"
+ class="flex items-center justify-between gap-4"
+ >
+ <FormLabel class="glass-label mb-0!">{{
+ $t("interfaces.http_tunnel_tls_verify")
+ }}</FormLabel>
+ <Toggle v-model="newInterfaceHttpTlsVerify" />
+ </div>
+ </div>
+ <BundledDocsHint
+ hint-i18n-key="interfaces.http_tunnel_docs_hint"
+ link-i18n-key="interfaces.http_tunnel_docs_link"
+ :docs-rel-path="docsReticulumInterfacesOverview"
+ paragraph-class="text-xs text-gray-500 dark:text-gray-400"
+ />
+ </div>
+
<!-- LocalInterface: IPC path used internally by RNS. Optional external module -->
<div v-if="newInterfaceType === 'LocalInterface'" class="space-y-4">
<div class="text-sm text-gray-800 dark:text-zinc-200 leading-snug">
@@ -1594,7 +1786,7 @@
<template #content>
<div class="p-6 space-y-6">
<div class="grid grid-cols-2 gap-4">
- <div>
+ <div v-if="newInterfaceType !== 'HTTPInterface'">
<FormLabel class="glass-label">Interface Mode</FormLabel>
<select v-model="sharedInterfaceSettings.mode" class="input-field">
<option :value="undefined">
@@ -1618,6 +1810,11 @@
</option>
</select>
</div>
+ <div v-else class="col-span-2">
+ <p class="text-xs text-gray-500 dark:text-zinc-400">
+ {{ $t("interfaces.http_tunnel_mode_note") }}
+ </p>
+ </div>
<div>
<FormLabel class="glass-label">Forced Bitrate</FormLabel>
<input
@@ -1990,6 +2187,18 @@ export default {
newInterfaceFastFlappingBlockTime: 720,
newInterfaceFastFlappingThreshold: 20,
newInterfaceFastFlappingGrace: 5,
+ newInterfaceHttpTunnelMode: "client",
+ newInterfaceHttpServerUrl: null,
+ newInterfaceHttpPollInterval: 0.1,
+ newInterfaceHttpListenHost: "0.0.0.0",
+ newInterfaceHttpListenPort: 8080,
+ newInterfaceHttpMtu: 4096,
+ newInterfaceHttpVersion: 1,
+ newInterfaceHttpUserAgent: "RNS-HTTP-Tunnel/1.0",
+ newInterfaceHttpCheckUserAgent: true,
+ newInterfaceHttpTlsVerify: true,
+ newInterfaceHttpTlsCertfile: null,
+ newInterfaceHttpTlsKeyfile: null,
reticulumInstance: {
share_instance: true,
local_hops_delta: false,
@@ -2615,6 +2824,30 @@ export default {
this.newInterfaceDataPort = iface.data_port ?? null;
this.newInterfaceConfiguredBitrate = iface.configured_bitrate ?? null;
+ if (iface.type === "HTTPInterface") {
+ const tunnelMode = String(iface.mode || "client").toLowerCase();
+ this.newInterfaceHttpTunnelMode = tunnelMode === "server" ? "server" : "client";
+ this.newInterfaceHttpServerUrl = iface.server_url ?? null;
+ this.newInterfaceHttpPollInterval =
+ iface.poll_interval != null && iface.poll_interval !== "" ? Number(iface.poll_interval) : 0.1;
+ this.newInterfaceHttpListenHost = iface.listen_host ?? "0.0.0.0";
+ this.newInterfaceHttpListenPort = iface.listen_port ?? 8080;
+ this.newInterfaceHttpMtu = iface.mtu != null && iface.mtu !== "" ? Number(iface.mtu) : 4096;
+ this.newInterfaceHttpVersion =
+ iface.http_version != null && iface.http_version !== "" ? Number(iface.http_version) : 1;
+ this.newInterfaceHttpUserAgent = iface.user_agent ?? "RNS-HTTP-Tunnel/1.0";
+ this.newInterfaceHttpCheckUserAgent =
+ iface.check_user_agent === undefined || iface.check_user_agent === null
+ ? true
+ : this.parseBool(iface.check_user_agent);
+ this.newInterfaceHttpTlsVerify =
+ iface.tls_verify === undefined || iface.tls_verify === null
+ ? true
+ : this.parseBool(iface.tls_verify);
+ this.newInterfaceHttpTlsCertfile = iface.tls_certfile ?? null;
+ this.newInterfaceHttpTlsKeyfile = iface.tls_keyfile ?? null;
+ }
+
this.newInterfaceFlowControl = this.parseBool(iface.flow_control);
this.newInterfaceCallsign = iface.callsign ?? null;
this.newInterfaceIDCallsign = iface.id_callsign ?? null;
@@ -2649,7 +2882,7 @@ export default {
this.newInterfaceCodingRate = iface.codingrate;
this.newInterfaceCommand = iface.command;
this.newInterfaceRespawnDelay = iface.respawn_delay;
- this.sharedInterfaceSettings.mode = iface.mode;
+ this.sharedInterfaceSettings.mode = iface.type === "HTTPInterface" ? null : iface.mode;
this.sharedInterfaceSettings.bitrate = iface.bitrate;
this.sharedInterfaceSettings.network_name = iface.network_name;
this.sharedInterfaceSettings.passphrase = iface.passphrase;
@@ -3180,6 +3413,18 @@ export default {
}
}
+ if (this.newInterfaceType === "HTTPInterface") {
+ if (this.newInterfaceHttpTunnelMode === "client") {
+ if (!(this.newInterfaceHttpServerUrl || "").trim()) {
+ ToastUtils.error(this.$t("interfaces.http_tunnel_server_url_required"));
+ return;
+ }
+ } else if (this.newInterfaceHttpListenPort == null || this.newInterfaceHttpListenPort === "") {
+ ToastUtils.error(this.$t("interfaces.http_tunnel_listen_port_required"));
+ return;
+ }
+ }
+
if (this.newInterfaceType === "__external__") {
const typeStr = (this.customExternalTypeName || "").trim();
if (!typeStr) {
@@ -3264,7 +3509,9 @@ export default {
: this.newInterfaceListenIp,
listen_port: isBackboneListener
? this.newInterfaceBackboneListenPort || null
- : this.newInterfaceListenPort,
+ : this.newInterfaceType === "HTTPInterface" && this.newInterfaceHttpTunnelMode === "server"
+ ? this.newInterfaceHttpListenPort
+ : this.newInterfaceListenPort,
forward_ip: this.newInterfaceForwardIp,
forward_port: this.newInterfaceForwardPort,
device: isBackboneListener
@@ -3327,6 +3574,39 @@ export default {
configured_bitrate: this.numOrNull(this.newInterfaceConfiguredBitrate),
command: this.newInterfaceCommand,
respawn_delay: this.newInterfaceRespawnDelay,
+ server_url:
+ this.newInterfaceType === "HTTPInterface" && this.newInterfaceHttpTunnelMode === "client"
+ ? (this.newInterfaceHttpServerUrl || "").trim() || null
+ : null,
+ poll_interval:
+ this.newInterfaceType === "HTTPInterface" && this.newInterfaceHttpTunnelMode === "client"
+ ? this.numOrNull(this.newInterfaceHttpPollInterval)
+ : null,
+ listen_host:
+ this.newInterfaceType === "HTTPInterface" && this.newInterfaceHttpTunnelMode === "server"
+ ? (this.newInterfaceHttpListenHost || "").trim() || "0.0.0.0"
+ : null,
+ mtu: this.newInterfaceType === "HTTPInterface" ? this.numOrNull(this.newInterfaceHttpMtu) : null,
+ http_version:
+ this.newInterfaceType === "HTTPInterface" ? this.numOrNull(this.newInterfaceHttpVersion) : null,
+ user_agent:
+ this.newInterfaceType === "HTTPInterface"
+ ? (this.newInterfaceHttpUserAgent || "").trim() || null
+ : null,
+ check_user_agent:
+ this.newInterfaceType === "HTTPInterface" && this.newInterfaceHttpTunnelMode === "server"
+ ? this.newInterfaceHttpCheckUserAgent === true
+ : null,
+ tls_verify:
+ this.newInterfaceType === "HTTPInterface" ? this.newInterfaceHttpTlsVerify === true : null,
+ tls_certfile:
+ this.newInterfaceType === "HTTPInterface"
+ ? (this.newInterfaceHttpTlsCertfile || "").trim() || null
+ : null,
+ tls_keyfile:
+ this.newInterfaceType === "HTTPInterface"
+ ? (this.newInterfaceHttpTlsKeyfile || "").trim() || null
+ : null,
discoverable: discoveryEnabled ? "yes" : null,
discovery_name: discoveryEnabled ? this.discovery.discovery_name : null,
announce_interval: discoveryEnabled
@@ -3345,7 +3625,10 @@ export default {
discovery_frequency: discoveryEnabled ? this.numOrNull(this.discovery.discovery_frequency) : null,
discovery_bandwidth: discoveryEnabled ? this.numOrNull(this.discovery.discovery_bandwidth) : null,
discovery_modulation: discoveryEnabled ? this.numOrNull(this.discovery.discovery_modulation) : null,
- mode: this.sharedInterfaceSettings.mode || null,
+ mode:
+ this.newInterfaceType === "HTTPInterface"
+ ? this.newInterfaceHttpTunnelMode || "client"
+ : this.sharedInterfaceSettings.mode || null,
recursive_prs: this.sharedInterfaceSettings.recursive_prs === true,
announces_from_internal: this.sharedInterfaceSettings.announces_from_internal !== false,
bitrate: this.sharedInterfaceSettings.bitrate,
@@ -3437,6 +3720,7 @@ export default {
"PipeInterface",
"AutoInterface",
"LocalInterface",
+ "HTTPInterface",
]);
return builtin.has(t);
},

diff --git a/meshchatx/src/frontend/components/interfaces/Interface.vue b/meshchatx/src/frontend/components/interfaces/Interface.vue
index fc35f516..39927ffd 100644
--- a/meshchatx/src/frontend/components/interfaces/Interface.vue
+++ b/meshchatx/src/frontend/components/interfaces/Interface.vue
@@ -241,6 +241,8 @@ export default {
return "eye";
case "PipeInterface":
return "pipe";
+ case "HTTPInterface":
+ return "web";
default:
return "server-network";
}
@@ -249,6 +251,13 @@ export default {
if (this.iface.type === "TCPClientInterface") {
return `${this.iface.target_host}:${this.iface.target_port}`;
}
+ if (this.iface.type === "HTTPInterface") {
+ const tunnelMode = String(this.iface.mode || "").toLowerCase();
+ if (tunnelMode === "server") {
+ return `HTTP ${this.iface.listen_host || "0.0.0.0"}:${this.iface.listen_port}`;
+ }
+ return this.iface.server_url || "HTTP tunnel client";
+ }
if (this.iface.type === "TCPServerInterface" || this.iface.type === "UDPInterface") {
return `${this.iface.listen_ip}:${this.iface.listen_port}`;
}

diff --git a/meshchatx/src/frontend/components/map/internal/discoveredIcons.js b/meshchatx/src/frontend/components/map/internal/discoveredIcons.js
index b69f66a9..1e2fb427 100644
--- a/meshchatx/src/frontend/components/map/internal/discoveredIcons.js
+++ b/meshchatx/src/frontend/components/map/internal/discoveredIcons.js
@@ -33,6 +33,8 @@ export function getDiscoveredIconName(node) {
return "eye";
case "PipeInterface":
return "pipe";
+ case "HTTPInterface":
+ return "web";
default:
return "server-network";
}

diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index bb8f849b..5bb99ea7 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -1288,7 +1288,8 @@
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
- "path_table_count": "{count} paths"
+ "path_table_count": "{count} paths",
+ "git_commit": "Commit {commit}"
},
"interfaces": {
"title": "Schnittstellen",
@@ -1417,7 +1418,27 @@
"block_fast_flapping_hint": "Ignoriert Clients, die sich zu schnell verbinden und trennen (RNS 1.4.0 BackboneInterface-Standard).",
"fast_flapping_block_time_label": "Sperrzeit (Minuten)",
"fast_flapping_threshold_label": "Schwelle (Sekunden)",
- "fast_flapping_grace_label": "Kulanz-Flaps"
+ "fast_flapping_grace_label": "Kulanz-Flaps",
+ "http_tunnel_intro": "Tunneln Sie Reticulum über HTTP-POST (RNS-over-HTTP). Nützlich, wenn nur Webverkehr erlaubt ist. Das gebündelte HTTPInterface-Modul wird automatisch in Ihren Reticulum-interfacepath installiert.",
+ "http_tunnel_mode_client": "Client",
+ "http_tunnel_mode_server": "Server",
+ "http_tunnel_mode_note": "Bei HTTP-Tunneln bedeutet Modus Client oder Server (nicht Reticulum full/gateway/roaming).",
+ "http_tunnel_server_url": "Server-URL",
+ "http_tunnel_server_url_required": "Server-URL ist für den HTTP-Tunnel-Clientmodus erforderlich.",
+ "http_tunnel_poll_interval": "Abfrageintervall (Sekunden)",
+ "http_tunnel_listen_host": "Listen-Host",
+ "http_tunnel_listen_port": "Listen-Port",
+ "http_tunnel_listen_port_required": "Listen-Port ist für den HTTP-Tunnel-Servermodus erforderlich.",
+ "http_tunnel_check_user_agent": "Passenden User-Agent verlangen",
+ "http_tunnel_check_user_agent_hint": "POST-Anfragen ohne den konfigurierten User-Agent ablehnen.",
+ "http_tunnel_http_version": "HTTP-Version",
+ "http_tunnel_http_version_hint": "HTTP/1.1 ist Standard. HTTP/2 und HTTP/3 benötigen TLS und zusätzliche Python-Pakete (hypercorn/aioquic) auf dem Server.",
+ "http_tunnel_mtu": "MTU",
+ "http_tunnel_user_agent": "User-Agent",
+ "http_tunnel_tls_hint": "HTTP/2 und HTTP/3 erfordern https:// auf dem Client und TLS-Zertifikatpfade auf dem Server.",
+ "http_tunnel_tls_verify": "TLS-Zertifikate prüfen",
+ "http_tunnel_docs_hint": "Siehe das Kapitel Interfaces für allgemeine Reticulum-Interface-Optionen.",
+ "http_tunnel_docs_link": "Gebündeltes Reticulum-Handbuch öffnen"
},
"map": {
"title": "Karte",

diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index ea3cbf9d..d65cf6a5 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -266,7 +266,7 @@
"preferred_node_placeholder": "Destination hash, e.g. a39610c89d18bb48c73e429582423c24",
"fallback_node_description": "Messages fallback to this node whenever direct delivery fails.",
"auto_select_node": "Auto-select best node",
- "auto_select_node_description": "Automatically find and switch to the best available propagation node based on network hops.",
+ "auto_select_node_description": "Find a usable propagation peer from local announces and Reticulum paths, verify with a small sync probe, and remember good destination hashes for this identity.",
"auto_select_using_label": "Using propagation node",
"auto_select_pending": "No propagation node selected yet. The app will choose one when available.",
"incoming_message_size": "Incoming message size",
@@ -1041,6 +1041,7 @@
"title": "About",
"third_party_licenses": "Third-party licenses",
"version": "v{version}",
+ "git_commit": "commit {commit}",
"ui_build": "UI build {date}",
"rns_version": "RNS {version}",
"lxmf_version": "LXMF {version}",
@@ -1316,6 +1317,26 @@
"custom_external_docs_link": "Open bundled Reticulum manual",
"custom_external_type_required": "Enter the Reticulum interface type name.",
"custom_external_json_invalid": "Invalid JSON for extra options.",
+ "http_tunnel_intro": "Tunnel Reticulum over HTTP POST (RNS-over-HTTP). Useful when only web traffic is allowed. The bundled HTTPInterface module is installed into your Reticulum interfacepath automatically.",
+ "http_tunnel_mode_client": "Client",
+ "http_tunnel_mode_server": "Server",
+ "http_tunnel_mode_note": "For HTTP tunnels, mode means client or server (not Reticulum full/gateway/roaming).",
+ "http_tunnel_server_url": "Server URL",
+ "http_tunnel_server_url_required": "Server URL is required for HTTP tunnel client mode.",
+ "http_tunnel_poll_interval": "Poll interval (seconds)",
+ "http_tunnel_listen_host": "Listen host",
+ "http_tunnel_listen_port": "Listen port",
+ "http_tunnel_listen_port_required": "Listen port is required for HTTP tunnel server mode.",
+ "http_tunnel_check_user_agent": "Require matching User-Agent",
+ "http_tunnel_check_user_agent_hint": "Reject POSTs that do not use the configured User-Agent string.",
+ "http_tunnel_http_version": "HTTP version",
+ "http_tunnel_http_version_hint": "HTTP/1.1 is the default. HTTP/2 and HTTP/3 need TLS and extra Python packages (hypercorn/aioquic) on the server.",
+ "http_tunnel_mtu": "MTU",
+ "http_tunnel_user_agent": "User-Agent",
+ "http_tunnel_tls_hint": "HTTP/2 and HTTP/3 require https:// on the client and TLS certificate paths on the server.",
+ "http_tunnel_tls_verify": "Verify TLS certificates",
+ "http_tunnel_docs_hint": "See the Interfaces chapter for general Reticulum interface options.",
+ "http_tunnel_docs_link": "Open bundled Reticulum manual",
"failed_save_discovery": "Failed to save discovery settings",
"no_interfaces_found_config": "No interfaces were found in the selected configuration file",
"failed_parse_config": "Failed to parse configuration file",

diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index 3df8ffec..bc84484f 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -1236,7 +1236,8 @@
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
- "path_table_count": "{count} paths"
+ "path_table_count": "{count} paths",
+ "git_commit": "commit {commit}"
},
"interfaces": {
"title": "Interfaces",
@@ -1365,7 +1366,27 @@
"block_fast_flapping_hint": "Ignora clientes que se conectan y desconectan demasiado rápido (valores por defecto de BackboneInterface en RNS 1.4.0).",
"fast_flapping_block_time_label": "Tiempo de bloqueo (minutos)",
"fast_flapping_threshold_label": "Umbral (segundos)",
- "fast_flapping_grace_label": "Flaps de gracia"
+ "fast_flapping_grace_label": "Flaps de gracia",
+ "http_tunnel_intro": "Tuneliza Reticulum sobre HTTP POST (RNS-over-HTTP). Útil cuando solo se permite tráfico web. El módulo HTTPInterface incluido se instala automáticamente en el interfacepath de Reticulum.",
+ "http_tunnel_mode_client": "Cliente",
+ "http_tunnel_mode_server": "Servidor",
+ "http_tunnel_mode_note": "En túneles HTTP, modo significa cliente o servidor (no full/gateway/roaming de Reticulum).",
+ "http_tunnel_server_url": "URL del servidor",
+ "http_tunnel_server_url_required": "La URL del servidor es obligatoria en el modo cliente del túnel HTTP.",
+ "http_tunnel_poll_interval": "Intervalo de sondeo (segundos)",
+ "http_tunnel_listen_host": "Host de escucha",
+ "http_tunnel_listen_port": "Puerto de escucha",
+ "http_tunnel_listen_port_required": "El puerto de escucha es obligatorio en el modo servidor del túnel HTTP.",
+ "http_tunnel_check_user_agent": "Exigir User-Agent coincidente",
+ "http_tunnel_check_user_agent_hint": "Rechazar POSTs que no usen el User-Agent configurado.",
+ "http_tunnel_http_version": "Versión HTTP",
+ "http_tunnel_http_version_hint": "HTTP/1.1 es el valor predeterminado. HTTP/2 y HTTP/3 necesitan TLS y paquetes Python adicionales (hypercorn/aioquic) en el servidor.",
+ "http_tunnel_mtu": "MTU",
+ "http_tunnel_user_agent": "User-Agent",
+ "http_tunnel_tls_hint": "HTTP/2 y HTTP/3 requieren https:// en el cliente y rutas de certificado TLS en el servidor.",
+ "http_tunnel_tls_verify": "Verificar certificados TLS",
+ "http_tunnel_docs_hint": "Consulta el capítulo Interfaces para opciones generales de interfaz de Reticulum.",
+ "http_tunnel_docs_link": "Abrir el manual de Reticulum incluido"
},
"map": {
"title": "Mapa",

diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index db69c0dd..c111b29a 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -1236,7 +1236,8 @@
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
- "path_table_count": "{count} paths"
+ "path_table_count": "{count} paths",
+ "git_commit": "commit {commit}"
},
"interfaces": {
"title": "Sovittimet",
@@ -1365,7 +1366,27 @@
"block_fast_flapping_hint": "Ohittaa asiakkaat, jotka yhdistavat ja katkaisevat liian nopeasti (RNS 1.4.0 BackboneInterface-oletukset).",
"fast_flapping_block_time_label": "Estoaika (minuuttia)",
"fast_flapping_threshold_label": "Kynnys (sekuntia)",
- "fast_flapping_grace_label": "Armo-flapit"
+ "fast_flapping_grace_label": "Armo-flapit",
+ "http_tunnel_intro": "Tunneloi Reticulum HTTP POST -yhteyden yli (RNS-over-HTTP). Hyödyllinen kun vain web-liikenne on sallittu. Mukana tuleva HTTPInterface-moduuli asennetaan automaattisesti Reticulum-interfacepathiin.",
+ "http_tunnel_mode_client": "Asiakas",
+ "http_tunnel_mode_server": "Palvelin",
+ "http_tunnel_mode_note": "HTTP-tunneleissa tila tarkoittaa asiakasta tai palvelinta (ei Reticulum full/gateway/roaming).",
+ "http_tunnel_server_url": "Palvelimen URL",
+ "http_tunnel_server_url_required": "Palvelimen URL vaaditaan HTTP-tunnelin asiakastilassa.",
+ "http_tunnel_poll_interval": "Kyselyväli (sekuntia)",
+ "http_tunnel_listen_host": "Kuunteluosoite",
+ "http_tunnel_listen_port": "Kuunteluportti",
+ "http_tunnel_listen_port_required": "Kuunteluportti vaaditaan HTTP-tunnelin palvelintilassa.",
+ "http_tunnel_check_user_agent": "Vaadi vastaava User-Agent",
+ "http_tunnel_check_user_agent_hint": "Hylkää POSTit jotka eivät käytä määritettyä User-Agent-merkkijonoa.",
+ "http_tunnel_http_version": "HTTP-versio",
+ "http_tunnel_http_version_hint": "HTTP/1.1 on oletus. HTTP/2 ja HTTP/3 tarvitsevat TLS:n ja lisäpaketit (hypercorn/aioquic) palvelimella.",
+ "http_tunnel_mtu": "MTU",
+ "http_tunnel_user_agent": "User-Agent",
+ "http_tunnel_tls_hint": "HTTP/2 ja HTTP/3 edellyttävät https:// asiakkaalla ja TLS-varmenteiden polkuja palvelimella.",
+ "http_tunnel_tls_verify": "Tarkista TLS-varmenteet",
+ "http_tunnel_docs_hint": "Katso Interfaces-luku yleisistä Reticulum-rajapinta-asetuksista.",
+ "http_tunnel_docs_link": "Avaa mukana tuleva Reticulum-ohje"
},
"map": {
"title": "Kartta",

diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index 97a7fd64..96b85261 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -1236,7 +1236,8 @@
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
- "path_table_count": "{count} paths"
+ "path_table_count": "{count} paths",
+ "git_commit": "commit {commit}"
},
"interfaces": {
"title": "Interfaces",
@@ -1365,7 +1366,27 @@
"block_fast_flapping_hint": "Ignore les clients qui se connectent et se déconnectent trop rapidement (défauts BackboneInterface RNS 1.4.0).",
"fast_flapping_block_time_label": "Durée de blocage (minutes)",
"fast_flapping_threshold_label": "Seuil (secondes)",
- "fast_flapping_grace_label": "Flaps de grace"
+ "fast_flapping_grace_label": "Flaps de grace",
+ "http_tunnel_intro": "Tunnelisez Reticulum via HTTP POST (RNS-over-HTTP). Utile quand seul le trafic web est autorisé. Le module HTTPInterface inclus est installé automatiquement dans votre interfacepath Reticulum.",
+ "http_tunnel_mode_client": "Client",
+ "http_tunnel_mode_server": "Serveur",
+ "http_tunnel_mode_note": "Pour les tunnels HTTP, mode signifie client ou serveur (pas full/gateway/roaming Reticulum).",
+ "http_tunnel_server_url": "URL du serveur",
+ "http_tunnel_server_url_required": "L'URL du serveur est requise en mode client du tunnel HTTP.",
+ "http_tunnel_poll_interval": "Intervalle de sondage (secondes)",
+ "http_tunnel_listen_host": "Hôte d'écoute",
+ "http_tunnel_listen_port": "Port d'écoute",
+ "http_tunnel_listen_port_required": "Le port d'écoute est requis en mode serveur du tunnel HTTP.",
+ "http_tunnel_check_user_agent": "Exiger un User-Agent correspondant",
+ "http_tunnel_check_user_agent_hint": "Rejeter les POST qui n'utilisent pas le User-Agent configuré.",
+ "http_tunnel_http_version": "Version HTTP",
+ "http_tunnel_http_version_hint": "HTTP/1.1 est la valeur par défaut. HTTP/2 et HTTP/3 nécessitent TLS et des paquets Python supplémentaires (hypercorn/aioquic) sur le serveur.",
+ "http_tunnel_mtu": "MTU",
+ "http_tunnel_user_agent": "User-Agent",
+ "http_tunnel_tls_hint": "HTTP/2 et HTTP/3 exigent https:// côté client et des chemins de certificats TLS côté serveur.",
+ "http_tunnel_tls_verify": "Vérifier les certificats TLS",
+ "http_tunnel_docs_hint": "Voir le chapitre Interfaces pour les options générales d'interface Reticulum.",
+ "http_tunnel_docs_link": "Ouvrir le manuel Reticulum inclus"
},
"map": {
"title": "Carte",

diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index df8e4b40..b865eeb8 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -1288,7 +1288,8 @@
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
- "path_table_count": "{count} paths"
+ "path_table_count": "{count} paths",
+ "git_commit": "commit {commit}"
},
"interfaces": {
"title": "Interfacce",
@@ -1417,7 +1418,27 @@
"block_fast_flapping_hint": "Ignora client che si connettono e disconnettono troppo in fretta (default BackboneInterface RNS 1.4.0).",
"fast_flapping_block_time_label": "Tempo di blocco (minuti)",
"fast_flapping_threshold_label": "Soglia (secondi)",
- "fast_flapping_grace_label": "Flap di grazia"
+ "fast_flapping_grace_label": "Flap di grazia",
+ "http_tunnel_intro": "Tunnelizza Reticulum su HTTP POST (RNS-over-HTTP). Utile quando è consentito solo il traffico web. Il modulo HTTPInterface incluso viene installato automaticamente nell'interfacepath di Reticulum.",
+ "http_tunnel_mode_client": "Client",
+ "http_tunnel_mode_server": "Server",
+ "http_tunnel_mode_note": "Per i tunnel HTTP, mode significa client o server (non full/gateway/roaming di Reticulum).",
+ "http_tunnel_server_url": "URL del server",
+ "http_tunnel_server_url_required": "L'URL del server è obbligatorio in modalità client del tunnel HTTP.",
+ "http_tunnel_poll_interval": "Intervallo di polling (secondi)",
+ "http_tunnel_listen_host": "Host in ascolto",
+ "http_tunnel_listen_port": "Porta in ascolto",
+ "http_tunnel_listen_port_required": "La porta in ascolto è obbligatoria in modalità server del tunnel HTTP.",
+ "http_tunnel_check_user_agent": "Richiedi User-Agent corrispondente",
+ "http_tunnel_check_user_agent_hint": "Rifiuta i POST che non usano lo User-Agent configurato.",
+ "http_tunnel_http_version": "Versione HTTP",
+ "http_tunnel_http_version_hint": "HTTP/1.1 è l'impostazione predefinita. HTTP/2 e HTTP/3 richiedono TLS e pacchetti Python aggiuntivi (hypercorn/aioquic) sul server.",
+ "http_tunnel_mtu": "MTU",
+ "http_tunnel_user_agent": "User-Agent",
+ "http_tunnel_tls_hint": "HTTP/2 e HTTP/3 richiedono https:// sul client e percorsi dei certificati TLS sul server.",
+ "http_tunnel_tls_verify": "Verifica certificati TLS",
+ "http_tunnel_docs_hint": "Vedi il capitolo Interfaces per le opzioni generali delle interfacce Reticulum.",
+ "http_tunnel_docs_link": "Apri il manuale Reticulum incluso"
},
"map": {
"title": "Mappa",

diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index fc0b38b9..df3d1039 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -1236,7 +1236,8 @@
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
- "path_table_count": "{count} paths"
+ "path_table_count": "{count} paths",
+ "git_commit": "commit {commit}"
},
"interfaces": {
"title": "Interfaces",
@@ -1365,7 +1366,27 @@
"block_fast_flapping_hint": "Negeert clients die te snel verbinden en verbreken (RNS 1.4.0 BackboneInterface-standaarden).",
"fast_flapping_block_time_label": "Blokkeertijd (minuten)",
"fast_flapping_threshold_label": "Drempel (seconden)",
- "fast_flapping_grace_label": "Grace-flaps"
+ "fast_flapping_grace_label": "Grace-flaps",
+ "http_tunnel_intro": "Tunnel Reticulum over HTTP POST (RNS-over-HTTP). Handig wanneer alleen webverkeer is toegestaan. De meegeleverde HTTPInterface-module wordt automatisch in uw Reticulum-interfacepath geïnstalleerd.",
+ "http_tunnel_mode_client": "Client",
+ "http_tunnel_mode_server": "Server",
+ "http_tunnel_mode_note": "Voor HTTP-tunnels betekent mode client of server (niet Reticulum full/gateway/roaming).",
+ "http_tunnel_server_url": "Server-URL",
+ "http_tunnel_server_url_required": "Server-URL is vereist voor HTTP-tunnel clientmodus.",
+ "http_tunnel_poll_interval": "Poll-interval (seconden)",
+ "http_tunnel_listen_host": "Luisterhost",
+ "http_tunnel_listen_port": "Luisterpoort",
+ "http_tunnel_listen_port_required": "Luisterpoort is vereist voor HTTP-tunnel servermodus.",
+ "http_tunnel_check_user_agent": "Bijpassende User-Agent vereisen",
+ "http_tunnel_check_user_agent_hint": "Wijs POSTs af die niet de geconfigureerde User-Agent gebruiken.",
+ "http_tunnel_http_version": "HTTP-versie",
+ "http_tunnel_http_version_hint": "HTTP/1.1 is de standaard. HTTP/2 en HTTP/3 vereisen TLS en extra Python-pakketten (hypercorn/aioquic) op de server.",
+ "http_tunnel_mtu": "MTU",
+ "http_tunnel_user_agent": "User-Agent",
+ "http_tunnel_tls_hint": "HTTP/2 en HTTP/3 vereisen https:// op de client en TLS-certificaatpaden op de server.",
+ "http_tunnel_tls_verify": "TLS-certificaten verifiëren",
+ "http_tunnel_docs_hint": "Zie het Interfaces-hoofdstuk voor algemene Reticulum-interfaceopties.",
+ "http_tunnel_docs_link": "Open de meegeleverde Reticulum-handleiding"
},
"map": {
"title": "Kaart",

diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 90812a7a..344ce6ca 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -1288,7 +1288,8 @@
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
- "path_table_count": "{count} paths"
+ "path_table_count": "{count} paths",
+ "git_commit": "коммит {commit}"
},
"interfaces": {
"title": "Интерфейсы",
@@ -1417,7 +1418,27 @@
"block_fast_flapping_hint": "Игнорирует клиентов, которые слишком быстро подключаются и отключаются (стандарты BackboneInterface RNS 1.4.0).",
"fast_flapping_block_time_label": "Время блокировки (минуты)",
"fast_flapping_threshold_label": "Порог (секунды)",
- "fast_flapping_grace_label": "Льготные flaps"
+ "fast_flapping_grace_label": "Льготные flaps",
+ "http_tunnel_intro": "Туннелируйте Reticulum через HTTP POST (RNS-over-HTTP). Полезно, когда разрешён только веб-трафик. Встроенный модуль HTTPInterface автоматически устанавливается в interfacepath Reticulum.",
+ "http_tunnel_mode_client": "Клиент",
+ "http_tunnel_mode_server": "Сервер",
+ "http_tunnel_mode_note": "Для HTTP-туннелей mode означает client или server (не full/gateway/roaming Reticulum).",
+ "http_tunnel_server_url": "URL сервера",
+ "http_tunnel_server_url_required": "URL сервера обязателен для клиентского режима HTTP-туннеля.",
+ "http_tunnel_poll_interval": "Интервал опроса (секунды)",
+ "http_tunnel_listen_host": "Адрес прослушивания",
+ "http_tunnel_listen_port": "Порт прослушивания",
+ "http_tunnel_listen_port_required": "Порт прослушивания обязателен для серверного режима HTTP-туннеля.",
+ "http_tunnel_check_user_agent": "Требовать совпадающий User-Agent",
+ "http_tunnel_check_user_agent_hint": "Отклонять POST без настроенной строки User-Agent.",
+ "http_tunnel_http_version": "Версия HTTP",
+ "http_tunnel_http_version_hint": "HTTP/1.1 по умолчанию. HTTP/2 и HTTP/3 требуют TLS и доп. пакеты Python (hypercorn/aioquic) на сервере.",
+ "http_tunnel_mtu": "MTU",
+ "http_tunnel_user_agent": "User-Agent",
+ "http_tunnel_tls_hint": "HTTP/2 и HTTP/3 требуют https:// на клиенте и пути TLS-сертификатов на сервере.",
+ "http_tunnel_tls_verify": "Проверять TLS-сертификаты",
+ "http_tunnel_docs_hint": "См. главу Interfaces для общих параметров интерфейсов Reticulum.",
+ "http_tunnel_docs_link": "Открыть встроенное руководство Reticulum"
},
"map": {
"title": "Карта",

diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 280ed460..742dff60 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -1236,7 +1236,8 @@
"top_cpu_consumer": "Top CPU",
"memory_pressure_normal": "Normal",
"path_table": "Path table",
- "path_table_count": "{count} paths"
+ "path_table_count": "{count} paths",
+ "git_commit": "提交 {commit}"
},
"interfaces": {
"title": "接口",
@@ -1365,7 +1366,27 @@
"block_fast_flapping_hint": "忽略连接与断开过快的客户端(RNS 1.4.0 BackboneInterface 默认值)。",
"fast_flapping_block_time_label": "封锁时间(分钟)",
"fast_flapping_threshold_label": "阈值(秒)",
- "fast_flapping_grace_label": "宽限抖动次数"
+ "fast_flapping_grace_label": "宽限抖动次数",
+ "http_tunnel_intro": "通过 HTTP POST 隧道传输 Reticulum(RNS-over-HTTP)。在仅允许 Web 流量时很有用。捆绑的 HTTPInterface 模块会自动安装到 Reticulum 的 interfacepath。",
+ "http_tunnel_mode_client": "客户端",
+ "http_tunnel_mode_server": "服务器",
+ "http_tunnel_mode_note": "对于 HTTP 隧道,mode 表示客户端或服务器(不是 Reticulum 的 full/gateway/roaming)。",
+ "http_tunnel_server_url": "服务器 URL",
+ "http_tunnel_server_url_required": "HTTP 隧道客户端模式需要服务器 URL。",
+ "http_tunnel_poll_interval": "轮询间隔(秒)",
+ "http_tunnel_listen_host": "监听主机",
+ "http_tunnel_listen_port": "监听端口",
+ "http_tunnel_listen_port_required": "HTTP 隧道服务器模式需要监听端口。",
+ "http_tunnel_check_user_agent": "要求匹配的 User-Agent",
+ "http_tunnel_check_user_agent_hint": "拒绝未使用已配置 User-Agent 字符串的 POST。",
+ "http_tunnel_http_version": "HTTP 版本",
+ "http_tunnel_http_version_hint": "默认 HTTP/1.1。HTTP/2 和 HTTP/3 需要 TLS,并在服务器上安装额外 Python 包(hypercorn/aioquic)。",
+ "http_tunnel_mtu": "MTU",
+ "http_tunnel_user_agent": "User-Agent",
+ "http_tunnel_tls_hint": "HTTP/2 和 HTTP/3 要求客户端使用 https://,服务器提供 TLS 证书路径。",
+ "http_tunnel_tls_verify": "验证 TLS 证书",
+ "http_tunnel_docs_hint": "有关常规 Reticulum 接口选项,请参阅 Interfaces 章节。",
+ "http_tunnel_docs_link": "打开捆绑的 Reticulum 手册"
},
"map": {
"title": "地图",

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/interfaces.md b/meshchatx/src/frontend/public/meshchatx-docs/en/interfaces.md
index edbc435f..97b3d276 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/interfaces.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/interfaces.md
@@ -24,6 +24,7 @@ The **Add interface** flow includes:
| KISSInterface | KISS TNC devices |
| I2PInterface | I2P-based Reticulum transport |
| AutoInterface | Automatic discovery on local networks |
+| HTTPInterface | HTTP/S tunnel (bundled RNS-over-HTTP) |
| Custom external types | Advanced setups |
Community-curated suggestions come from `community_interfaces.json`, sourced from [directory.rns.recipes](https://directory.rns.recipes).
@@ -44,6 +45,10 @@ LoRa setups often need firmware management. **Tools → RNode Flasher** opens th
MeshChatX includes a custom `WebsocketServerInterface` for WebSocket-based Reticulum transport. Use it when bridging to web-friendly gateways.
+## HTTP tunnel interface
+
+MeshChatX vendors [RNS-over-HTTP](https://github.com/Quad4-Software/RNS-over-HTTP) and installs `HTTPInterface.py` into your Reticulum `interfacepath` on startup. Use **Add interface → HTTP Tunnel** for client or server mode when only HTTP/S egress is available. Default transport is HTTP/1.1. HTTP/2 and HTTP/3 need TLS and optional extra packages on the server side.
+
## Getting onto the mesh
A minimal path for a new node:

diff --git a/meshchatx/src/frontend/public/meshchatx-docs/en/messaging.md b/meshchatx/src/frontend/public/meshchatx-docs/en/messaging.md
index 9a8df6c0..1fdd7b34 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/en/messaging.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/en/messaging.md
@@ -38,7 +38,7 @@ MeshChatX can:
- Run a **local propagation node** on your identity
- **Sync** with remote propagation nodes you trust
-- **Auto-select** a preferred node via `AutoPropagationManager`
+- **Auto-select** a preferred propagation peer via `AutoPropagationManager` from local `lxmf.propagation` announces and RNS paths (small sync probe, remembered destination hashes, no central directory)
- **Retry** failed direct deliveries through propagation when configured
Manage nodes from **Tools → Propagation nodes** or related settings entries.

diff --git a/package.json b/package.json
index 1487d4ee..25704bcd 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"build-docs:rns": "python3 scripts/build/fetch_reticulum_manual.py --force --via-rns",
"build-repository-wheels": "python3 scripts/build/fetch_repository_wheels.py",
"version:sync": "node scripts/sync_version.js",
+ "build-meta:bake": "node scripts/bake_build_meta.js",
"build": "pnpm run version:sync && pnpm run build-frontend && pnpm run build-docs && pnpm run build-repository-wheels && pnpm run build-backend",
"build:offline": "cross-env MESHCHATX_OFFLINE_BUILD=1 pnpm run build",
"bundle:offline": "bash scripts/create-offline-bundle.sh",

diff --git a/pyproject.toml b/pyproject.toml
index 440d3210..d9ba3a42 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,6 +37,7 @@ dependencies = [
"cbor2>=6.1.1",
"wasmtime>=28.0.0",
"bleak>=3.0.2,<4.0",
+ "httpx[http2]>=0.28.1,<1.0",
]
[project.scripts]
@@ -56,7 +57,7 @@ exclude = ["tests*"]
namespaces = false
[tool.setuptools.package-data]
-meshchatx = ["public/**/*", "public/*", "src/backend/data/community_interfaces.json", "src/backend/data/licenses_frontend.json", "src/backend/data/licenses_backend.json", "src/backend/data/THIRD_PARTY_NOTICES.txt", "src/backend/data/plugins/**/*", "src/frontend/public/repository-server-index.html"]
+meshchatx = ["public/**/*", "public/*", "src/backend/data/community_interfaces.json", "src/backend/data/licenses_frontend.json", "src/backend/data/licenses_backend.json", "src/backend/data/THIRD_PARTY_NOTICES.txt", "src/backend/data/plugins/**/*", "src/backend/data/interfaces/**/*", "src/frontend/public/repository-server-index.html"]
[tool.setuptools.exclude-package-data]
meshchatx = ["public/repository-server-bundled/**"]

diff --git a/scripts/bake_build_meta.js b/scripts/bake_build_meta.js
new file mode 100644
index 00000000..0df1656f
--- /dev/null
+++ b/scripts/bake_build_meta.js
@@ -0,0 +1,123 @@
+/**
+ * Bake git commit and build channel into meshchatx/src/_build_meta_baked.py
+ * (gitignored). meshchatx.src.build_meta imports that overlay when present.
+ *
+ * Invoked from scripts/sync_version.js (pnpm run version:sync / build).
+ *
+ * Channel detection (first match wins):
+ * MESHCHATX_BUILD_CHANNEL env
+ * GITHUB_REF_NAME / GITHUB_REF (nightly-*, preview-dev-*, preview-*, vX.Y.Z)
+ * default: local (is_dev)
+ *
+ * Dev channels (nightly, preview-dev, preview, local) set IS_DEV_BUILD.
+ * Release tags (vX.Y.Z) clear IS_DEV_BUILD.
+ */
+
+const fs = require("fs");
+const path = require("path");
+const { execFileSync } = require("child_process");
+
+const root = path.resolve(__dirname, "..");
+const metaPath = path.join(root, "meshchatx", "src", "_build_meta_baked.py");
+
+function envTrim(name) {
+ const v = process.env[name];
+ return typeof v === "string" ? v.trim() : "";
+}
+
+function git(args) {
+ try {
+ return execFileSync("git", args, {
+ cwd: root,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ }).trim();
+ } catch {
+ return "";
+ }
+}
+
+function resolveCommit() {
+ const fromEnv = envTrim("MESHCHATX_GIT_COMMIT") || envTrim("GIT_COMMIT") || envTrim("GITHUB_SHA");
+ if (fromEnv) {
+ return fromEnv;
+ }
+ return git(["rev-parse", "HEAD"]);
+}
+
+function shortCommit(full) {
+ if (!full) {
+ return "";
+ }
+ if (/^[0-9a-f]{7,40}$/i.test(full)) {
+ return full.slice(0, 7).toLowerCase();
+ }
+ const fromGit = git(["rev-parse", "--short=7", "HEAD"]);
+ return fromGit || full.slice(0, 7);
+}
+
+function refName() {
+ return (
+ envTrim("MESHCHATX_BUILD_REF") ||
+ envTrim("GITHUB_REF_NAME") ||
+ envTrim("GITHUB_REF").replace(/^refs\/(heads|tags)\//, "")
+ );
+}
+
+function resolveChannel(ref) {
+ const forced = envTrim("MESHCHATX_BUILD_CHANNEL").toLowerCase();
+ if (forced) {
+ return forced;
+ }
+ if (!ref) {
+ return "local";
+ }
+ if (ref.startsWith("nightly-")) {
+ return "nightly";
+ }
+ if (ref.startsWith("preview-dev-")) {
+ return "preview-dev";
+ }
+ if (ref.startsWith("preview-")) {
+ return "preview";
+ }
+ if (/^v\d+\.\d+\.\d+/.test(ref)) {
+ return "release";
+ }
+ return "local";
+}
+
+function isDevChannel(channel) {
+ return channel !== "release";
+}
+
+function pyString(value) {
+ return JSON.stringify(String(value ?? ""));
+}
+
+function bake() {
+ const commit = resolveCommit();
+ const short = shortCommit(commit);
+ const ref = refName();
+ const channel = resolveChannel(ref);
+ const isDev = isDevChannel(channel);
+
+ const content = `# SPDX-License-Identifier: 0BSD
+"""Generated by scripts/bake_build_meta.js. Do not commit."""
+
+GIT_COMMIT = ${pyString(commit)}
+GIT_COMMIT_SHORT = ${pyString(short)}
+BUILD_CHANNEL = ${pyString(channel)}
+IS_DEV_BUILD = ${isDev ? "True" : "False"}
+`;
+
+ const prev = fs.existsSync(metaPath) ? fs.readFileSync(metaPath, "utf8") : null;
+ if (prev !== content) {
+ fs.writeFileSync(metaPath, content, "utf8");
+ console.log(`Baked build_meta channel=${channel} is_dev=${isDev} commit=${short || "unknown"}`);
+ } else {
+ console.log(`build_meta unchanged channel=${channel} is_dev=${isDev} commit=${short || "unknown"}`);
+ }
+}
+
+bake();

diff --git a/scripts/build-android-wheels-local.sh b/scripts/build-android-wheels-local.sh
index 779d2607..e323756d 100755
--- a/scripts/build-android-wheels-local.sh
+++ b/scripts/build-android-wheels-local.sh
@@ -11,11 +11,13 @@ This script:
3) Builds pycodec2 Android wheels with Chaquopy's build-wheel tool
4) Optionally patches LXST wheel metadata for local Android constraints
5) Vendors the bleak pure-python wheel from PyPI
-6) Patches the rns wheel so its Android RNodeInterface never calls
+6) Vendors httpx[http2] and its pure-python dependency wheels from PyPI
+ (RNS-over-HTTP / HTTPInterface client support)
+7) Patches the rns wheel so its Android RNodeInterface never calls
RNS.panic() (os._exit) when usbserial4a/jnius are missing
-7) Builds every recipe under android/chaquopy-recipes/ for each requested
- ABI (currently: cryptography, miniaudio)
-8) Copies outputs to android/vendor
+8) Builds every recipe under android/chaquopy-recipes/ for each requested
+ ABI (currently: cryptography, miniaudio, aiohttp, cbor2, ...)
+9) Copies outputs to android/vendor
Usage:
scripts/build-android-wheels-local.sh [options]
@@ -30,6 +32,7 @@ Options:
--numpy-version V NumPy version used during pycodec2 build (default: 1.26.2)
--lxst-version V LXST wheel version for metadata patch (default: 0.5.0)
--bleak-version V bleak pure-python wheel version to vendor (default: 3.0.2)
+ --httpx-version V httpx pure-python wheel version to vendor (default: 0.28.1)
--rns-version V rns wheel version to patch (default: 1.4.0)
--no-lxst-patch Skip LXST metadata patch
--no-rns-patch Skip RNS Android RNodeInterface patch
@@ -60,6 +63,7 @@ LIBCODEC2_VERSION="1.2.0"
NUMPY_VERSION="1.26.2"
LXST_VERSION="0.5.0"
BLEAK_VERSION="3.0.2"
+HTTPX_VERSION="0.28.1"
RNS_VERSION="1.4.0"
PATCH_LXST="1"
PATCH_RNS="1"
@@ -105,6 +109,10 @@ while [[ $# -gt 0 ]]; do
BLEAK_VERSION="${2:?missing value for --bleak-version}"
shift 2
;;
+ --httpx-version)
+ HTTPX_VERSION="${2:?missing value for --httpx-version}"
+ shift 2
+ ;;
--rns-version)
RNS_VERSION="${2:?missing value for --rns-version}"
shift 2
@@ -838,6 +846,37 @@ PY
echo "Expected bleak-${BLEAK_VERSION}-py3-none-any.whl in ${OUT_DIR}" >&2
exit 1
fi
+
+ echo "Fetching httpx[http2] ${HTTPX_VERSION} pure-python wheels (and deps)"
+ HTTPX_TMP_DIR="$(mktemp -d)"
+ "${VENV_DIR}/bin/pip" download \
+ --only-binary=:all: \
+ "httpx[http2]==${HTTPX_VERSION}" \
+ --dest "${HTTPX_TMP_DIR}" \
+ --index-url https://pypi.org/simple
+
+ HTTPX_WHEEL="$(ls "${HTTPX_TMP_DIR}"/httpx-"${HTTPX_VERSION}"-py3-none-any.whl)"
+ cp -f "${HTTPX_WHEEL}" "${OUT_DIR}/"
+ # Vendor transitive pure-python deps so offline/find-links builds resolve.
+ for dep_wheel in "${HTTPX_TMP_DIR}"/*.whl; do
+ base="$(basename "${dep_wheel}")"
+ if [[ "${base}" == httpx-* ]]; then
+ continue
+ fi
+ cp -f "${dep_wheel}" "${OUT_DIR}/"
+ done
+ rm -rf "${HTTPX_TMP_DIR}"
+
+ if ! ls "${OUT_DIR}/httpx-${HTTPX_VERSION}-py3-none-any.whl" >/dev/null 2>&1; then
+ echo "Expected httpx-${HTTPX_VERSION}-py3-none-any.whl in ${OUT_DIR}" >&2
+ exit 1
+ fi
+ for required_prefix in httpcore- h2- h11- anyio- hpack- hyperframe-; do
+ if ! ls "${OUT_DIR}/${required_prefix}"*.whl >/dev/null 2>&1; then
+ echo "Expected ${required_prefix}*.whl in ${OUT_DIR} (httpx[http2] dependency)" >&2
+ exit 1
+ fi
+ done
fi
if [[ "${PATCH_RNS}" == "1" && -z "${ONLY_RECIPES}" ]]; then

diff --git a/scripts/live_auto_propagation_observe.py b/scripts/live_auto_propagation_observe.py
new file mode 100644
index 00000000..ddc60366
--- /dev/null
+++ b/scripts/live_auto_propagation_observe.py
@@ -0,0 +1,195 @@
+# SPDX-License-Identifier: 0BSD
+"""Live observation of AutoPropagationManager against a shared rnsd instance.
+
+Usage (from repo root, with rnsd already running as shared instance)::
+
+ MESHCHAT_LANDLOCK=0 MESHCHAT_SECCOMP=0 uv run python scripts/live_auto_propagation_observe.py
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import json
+import os
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+import RNS
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+from meshchatx.meshchat import ReticulumMeshChat # noqa: E402
+from meshchatx.src.backend.auto_propagation_manager import ( # noqa: E402
+ MEMORY_CONFIG_KEY,
+ AutoPropagationManager,
+)
+
+
+def _pretty(node_hex: str | None) -> str:
+ if not node_hex:
+ return "(none)"
+ try:
+ return RNS.prettyhexrep(bytes.fromhex(node_hex))
+ except Exception:
+ return node_hex[:12] + "..."
+
+
+async def main() -> int:
+ reticulum_config_dir = os.path.expanduser("~/.reticulum")
+ if not Path(reticulum_config_dir, "config").is_file():
+ print("FAIL: missing ~/.reticulum/config (need shared rnsd)")
+ return 2
+
+ tmp = tempfile.mkdtemp(prefix="mcx-live-autoprops-")
+ log_path = Path(tmp) / "observe.json"
+ print(f"temp storage: {tmp}")
+ print(f"reticulum config: {reticulum_config_dir}")
+
+ identity = RNS.Identity()
+ app = ReticulumMeshChat(
+ identity,
+ storage_dir=tmp,
+ reticulum_config_dir=reticulum_config_dir,
+ defer_network_setup=False,
+ headless=True,
+ plugins_enabled=False,
+ )
+
+ report: dict = {
+ "started_at": time.time(),
+ "network_ready": False,
+ "announce_wait_s": 45,
+ "propagation_announces": 0,
+ "candidates": {},
+ "before_preferred": None,
+ "after_preferred": None,
+ "memory": {},
+ "path_ok": None,
+ "hops": None,
+ "error": None,
+ }
+
+ try:
+ ready = app.wait_until_network_ready(timeout=90)
+ report["network_ready"] = bool(ready)
+ print(f"network_ready={ready}")
+ if not ready:
+ report["error"] = "network_not_ready"
+ log_path.write_text(json.dumps(report, indent=2) + "\n")
+ print(json.dumps(report, indent=2))
+ return 1
+
+ ctx = app.current_context
+ if ctx is None or ctx.auto_propagation_manager is None:
+ report["error"] = "no_auto_propagation_manager"
+ log_path.write_text(json.dumps(report, indent=2) + "\n")
+ print(json.dumps(report, indent=2))
+ return 1
+
+ manager: AutoPropagationManager = ctx.auto_propagation_manager
+ ctx.config.lxmf_preferred_propagation_node_auto_select.set(True)
+
+ print(f"waiting {report['announce_wait_s']}s for lxmf.propagation announces...")
+ await asyncio.sleep(report["announce_wait_s"])
+
+ announces = (
+ ctx.database.announces.get_announces(aspect="lxmf.propagation") or []
+ )
+ report["propagation_announces"] = len(announces)
+ print(f"propagation announces stored: {len(announces)}")
+
+ best, announced = manager._collect_candidates()
+ report["candidates"] = {
+ "count": len(best),
+ "announced_count": len(announced),
+ "sample": [
+ {
+ "dest": _pretty(h),
+ "hops": hops if hops < 10**8 else None,
+ "announced": h in announced,
+ "path_ok": manager._path_quality_ok(bytes.fromhex(h)),
+ }
+ for h, hops in list(best.items())[:8]
+ ],
+ }
+ print(
+ f"candidates={len(best)} announced={len(announced)} "
+ f"sample={report['candidates']['sample']}",
+ )
+
+ before = ctx.config.lxmf_preferred_propagation_node_destination_hash.get()
+ report["before_preferred"] = before
+ print(f"before preferred: {_pretty(before)}")
+
+ t0 = time.monotonic()
+ await manager.check_and_update_propagation_node()
+ elapsed = time.monotonic() - t0
+ report["check_elapsed_s"] = round(elapsed, 2)
+
+ after = ctx.config.lxmf_preferred_propagation_node_destination_hash.get()
+ report["after_preferred"] = after
+ raw_mem = ctx.config.get(MEMORY_CONFIG_KEY, default_value=None)
+ report["memory"] = json.loads(raw_mem) if raw_mem else {}
+ print(f"after preferred: {_pretty(after)} ({elapsed:.1f}s)")
+
+ if after:
+ dest = bytes.fromhex(after)
+ report["path_ok"] = manager._path_quality_ok(dest)
+ report["hops"] = manager._hops_to(dest)
+ outbound = ctx.message_router.get_outbound_propagation_node()
+ report["outbound_matches"] = (
+ outbound is not None and outbound.hex() == after.lower()
+ )
+ print(
+ f"path_ok={report['path_ok']} hops={report['hops']} "
+ f"outbound_matches={report['outbound_matches']}",
+ )
+
+ # Second pass should stay sticky without thrashing when recently verified.
+ if after:
+ await manager.check_and_update_propagation_node()
+ sticky = ctx.config.lxmf_preferred_propagation_node_destination_hash.get()
+ report["sticky_preferred"] = sticky
+ report["sticky_ok"] = sticky == after
+ print(f"sticky preferred: {_pretty(sticky)} ok={report['sticky_ok']}")
+
+ log_path.write_text(json.dumps(report, indent=2) + "\n")
+ print("--- report ---")
+ print(json.dumps(report, indent=2))
+
+ if not best:
+ print(
+ "NOTE: no enabled lxmf.propagation candidates heard yet. "
+ "Logic ran, but live selection could not be proven this cycle.",
+ )
+ return 0
+
+ if after and report.get("path_ok") and report.get("outbound_matches"):
+ print("PASS: selected verified preferred peer with usable path")
+ return 0
+
+ if after:
+ print("PARTIAL: preferred set but path/outbound not fully confirmed")
+ return 0
+
+ print("NOTE: check finished without selecting a preferred peer")
+ return 0
+ except Exception as exc:
+ report["error"] = f"{type(exc).__name__}: {exc}"
+ log_path.write_text(json.dumps(report, indent=2) + "\n")
+ print(json.dumps(report, indent=2))
+ raise
+ finally:
+ with contextlib.suppress(Exception):
+ if getattr(app, "current_context", None) is not None:
+ app.current_context.stop()
+ print(f"report file: {log_path}")
+
+
+if __name__ == "__main__":
+ raise SystemExit(asyncio.run(main()))

diff --git a/scripts/sync_version.js b/scripts/sync_version.js
index e7749a3e..2ddef745 100644
--- a/scripts/sync_version.js
+++ b/scripts/sync_version.js
@@ -8,7 +8,8 @@
* meshchatx/src/frontend/public/meshchatx-docs/meshchatx_on_raspberry_pi.md,
* meshchatx/src/backend/data/licenses_backend.json (reticulum-meshchatx entry),
* android/app/build.gradle,
- * pipx example, packaging/arch/PKGBUILD pkgver / printf fallback.
+ * pipx example, packaging/arch/PKGBUILD pkgver / printf fallback,
+ * then runs scripts/bake_build_meta.js (git commit / channel overlay).
*
* __version__ lives in meshchatx/__init__.py so Chaquopy/Android (which may not ship loose .py
* data files next to bytecode) always has a resolvable version. src/version.py stays for packaging and tools.
@@ -135,3 +136,9 @@ patchFile("packaging/arch/.SRCINFO", (c) =>
);
console.log(`Synced version ${version} from package.json`);
+
+try {
+ require("./bake_build_meta.js");
+} catch (err) {
+ console.warn(`bake_build_meta skipped: ${err && err.message ? err.message : err}`);
+}

diff --git a/tests/backend/test_app_endpoints.py b/tests/backend/test_app_endpoints.py
index a8e8e148..48f3444a 100644
--- a/tests/backend/test_app_endpoints.py
+++ b/tests/backend/test_app_endpoints.py
@@ -86,6 +86,11 @@ async def test_app_info_extended(mock_rns_minimal, temp_dir):
assert "lxst_version" in data["app_info"]
assert data["app_info"]["lxst_version"] == "1.2.3"
+ assert "display_version" in data["app_info"]
+ assert "git_commit" in data["app_info"]
+ assert "git_commit_short" in data["app_info"]
+ assert "is_dev_build" in data["app_info"]
+ assert "build_channel" in data["app_info"]
@pytest.mark.asyncio

diff --git a/tests/backend/test_auto_propagation.py b/tests/backend/test_auto_propagation.py
index c8e3e8cc..69429673 100644
--- a/tests/backend/test_auto_propagation.py
+++ b/tests/backend/test_auto_propagation.py
@@ -6,7 +6,12 @@ import pytest
import RNS
from LXMF.LXMRouter import LXMRouter
-from meshchatx.src.backend.auto_propagation_manager import AutoPropagationManager
+from meshchatx.src.backend.auto_propagation_manager import (
+ FAILURE_COOLDOWN_SECONDS,
+ MAX_CONSECUTIVE_FAILURES_BEFORE_CLEAR,
+ MEMORY_CONFIG_KEY,
+ AutoPropagationManager,
+)
_VALID_HASH_A = "01" * 16
_VALID_HASH_B = "02" * 16
@@ -15,11 +20,30 @@ _VALID_HASH_C = "03" * 16
_APP_DATA_ENABLED = b"\x94\x00\x00\x01\x00"
-def _make_manager():
+def _make_manager(memory_raw=None):
app = MagicMock()
context = MagicMock()
config = MagicMock()
database = MagicMock()
+ manager_store = {}
+
+ if memory_raw is not None:
+ manager_store[MEMORY_CONFIG_KEY] = memory_raw
+
+ config_manager = MagicMock()
+ config_manager.get.side_effect = lambda key, default_value=None: manager_store.get(
+ key,
+ default_value,
+ )
+
+ def _set(key, value):
+ manager_store[key] = value
+
+ config_manager.set.side_effect = _set
+ # Real ConfigManager exposes get/set on itself (not .manager).
+ config.get = config_manager.get
+ config.set = config_manager.set
+ config.manager = config_manager
context.config = config
context.database = database
@@ -27,14 +51,15 @@ def _make_manager():
context.running = True
context.message_router = MagicMock()
context.message_router.propagation_transfer_state = LXMRouter.PR_IDLE
+ context.message_router.get_outbound_propagation_node.return_value = None
manager = AutoPropagationManager(app, context)
- return manager, app, context, config, database
+ return manager, app, context, config, database, manager_store
@pytest.mark.asyncio
async def test_auto_propagation_logic():
- manager, app, context, config, database = _make_manager()
+ manager, app, context, config, database, _store = _make_manager()
config.lxmf_preferred_propagation_node_auto_select.get.return_value = False
with patch.object(manager, "check_and_update_propagation_node") as mock_check:
@@ -58,8 +83,13 @@ async def test_auto_propagation_logic():
with (
patch.object(RNS.Transport, "has_path", return_value=True),
patch.object(RNS.Transport, "hops_to") as mock_hops,
- patch.object(manager, "_wait_for_path", return_value=True),
+ patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
+ patch.object(manager, "_wait_for_usable_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", return_value=True),
+ patch(
+ "meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
+ return_value=False,
+ ),
):
mock_hops.side_effect = lambda dh: (
1 if dh == bytes.fromhex(_VALID_HASH_A) else 3
@@ -74,6 +104,8 @@ async def test_auto_propagation_logic():
config.lxmf_preferred_propagation_node_destination_hash.set.assert_called_with(
_VALID_HASH_A,
)
+ assert _VALID_HASH_A in manager._memory
+ assert manager._memory[_VALID_HASH_A]["successes"] >= 1
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
_VALID_HASH_B
@@ -83,8 +115,13 @@ async def test_auto_propagation_logic():
with (
patch.object(RNS.Transport, "has_path", return_value=True),
patch.object(RNS.Transport, "hops_to") as mock_hops,
- patch.object(manager, "_wait_for_path", return_value=True),
+ patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
+ patch.object(manager, "_wait_for_usable_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", side_effect=[False, True]),
+ patch(
+ "meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
+ return_value=False,
+ ),
):
mock_hops.side_effect = lambda dh: (
1 if dh == bytes.fromhex(_VALID_HASH_A) else 3
@@ -106,12 +143,18 @@ async def test_auto_propagation_logic():
}
database.announces.get_announces.return_value = [announce1, announce3]
app.set_active_propagation_node.reset_mock()
+ config.lxmf_preferred_propagation_node_destination_hash.set.reset_mock()
with (
patch.object(RNS.Transport, "has_path", return_value=True),
patch.object(RNS.Transport, "hops_to") as mock_hops,
- patch.object(manager, "_wait_for_path", return_value=True),
+ patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
+ patch.object(manager, "_wait_for_usable_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", return_value=True),
+ patch(
+ "meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
+ return_value=False,
+ ),
):
mock_hops.side_effect = lambda dh: (
1 if dh == bytes.fromhex(_VALID_HASH_A) else 2
@@ -119,7 +162,14 @@ async def test_auto_propagation_logic():
await manager.check_and_update_propagation_node()
- app.set_active_propagation_node.assert_not_called()
+ # Preferred C still works. Refresh binding but keep the same hash.
+ app.set_active_propagation_node.assert_called_with(
+ _VALID_HASH_C,
+ context=context,
+ )
+ config.lxmf_preferred_propagation_node_destination_hash.set.assert_called_with(
+ _VALID_HASH_C,
+ )
@pytest.mark.asyncio
@@ -129,7 +179,7 @@ async def test_auto_propagation_skips_when_sync_active_and_path_exists():
When a sync is active and the current node still has a path, the manager
should leave it alone so the transfer can finish.
"""
- manager, app, context, config, database = _make_manager()
+ manager, app, context, config, database, _store = _make_manager()
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
@@ -142,7 +192,7 @@ async def test_auto_propagation_skips_when_sync_active_and_path_exists():
patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
patch.object(
manager,
- "_wait_for_path",
+ "_wait_for_usable_path",
return_value=True,
),
patch(
@@ -163,7 +213,7 @@ async def test_auto_propagation_finds_new_node_when_sync_stuck_no_path():
The manager should stop the stuck sync and look for a working node.
"""
- manager, app, context, config, database = _make_manager()
+ manager, app, context, config, database, _store = _make_manager()
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
@@ -180,9 +230,14 @@ async def test_auto_propagation_finds_new_node_when_sync_stuck_no_path():
with (
patch.object(RNS.Transport, "has_path") as mock_has_path,
patch.object(RNS.Transport, "hops_to", return_value=1),
- patch.object(manager, "_wait_for_path", return_value=True),
+ patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
+ patch.object(manager, "_wait_for_usable_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", return_value=True),
patch("meshchatx.src.backend.auto_propagation_manager.asyncio.sleep"),
+ patch(
+ "meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
+ return_value=False,
+ ),
):
# Current node A has no path, candidate B has a path.
mock_has_path.side_effect = lambda dh: dh == bytes.fromhex(_VALID_HASH_B)
@@ -200,13 +255,9 @@ async def test_auto_propagation_finds_new_node_when_sync_stuck_no_path():
@pytest.mark.asyncio
-async def test_auto_propagation_removes_broken_node_when_all_candidates_fail():
- """Remove the active propagation node when no candidate works.
-
- When no candidate works and the previous node is unreachable, the active
- propagation node should be removed instead of restoring a broken one.
- """
- manager, app, context, config, database = _make_manager()
+async def test_auto_propagation_keeps_preferred_until_repeated_failures():
+ """Transient probe failures should not wipe a preferred node immediately."""
+ manager, app, context, config, database, _store = _make_manager()
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
@@ -221,18 +272,36 @@ async def test_auto_propagation_removes_broken_node_when_all_candidates_fail():
with (
patch.object(RNS.Transport, "has_path", return_value=False),
- patch.object(manager, "_wait_for_path", return_value=False),
+ patch.object(manager, "_wait_for_usable_path", return_value=False),
+ patch.object(RNS.Transport, "request_path") as mock_request_path,
):
await manager.check_and_update_propagation_node()
- app.set_active_propagation_node.assert_not_called()
- app.remove_active_propagation_node.assert_called_once_with(context=context)
+ app.remove_active_propagation_node.assert_not_called()
+ app.set_active_propagation_node.assert_called_with(_VALID_HASH_A, context=context)
+ mock_request_path.assert_called()
+ assert manager._memory.get(_VALID_HASH_B, {}).get("consecutive_failures", 0) >= 1
@pytest.mark.asyncio
-async def test_auto_propagation_clears_previous_even_when_path_still_exists():
- """Do not restore a sync-broken previous node just because has_path is true."""
- manager, app, context, config, database = _make_manager()
+async def test_auto_propagation_clears_after_repeated_failures():
+ """Clear the preferred node after repeated verify failures."""
+ import json
+ import time
+
+ memory = {
+ _VALID_HASH_A: {
+ "successes": 2,
+ "failures": MAX_CONSECUTIVE_FAILURES_BEFORE_CLEAR - 1,
+ "consecutive_failures": MAX_CONSECUTIVE_FAILURES_BEFORE_CLEAR - 1,
+ "last_success_at": int(time.time()) - 60,
+ "last_failure_at": int(time.time()) - 30,
+ "last_hops": 2,
+ },
+ }
+ manager, app, context, config, database, _store = _make_manager(
+ memory_raw=json.dumps(memory),
+ )
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
@@ -240,31 +309,38 @@ async def test_auto_propagation_clears_previous_even_when_path_still_exists():
)
announce1 = {
+ "destination_hash": _VALID_HASH_A,
+ "app_data": _APP_DATA_ENABLED,
+ }
+ announce2 = {
"destination_hash": _VALID_HASH_B,
"app_data": _APP_DATA_ENABLED,
}
- database.announces.get_announces.return_value = [announce1]
+ database.announces.get_announces.return_value = [announce1, announce2]
with (
patch.object(RNS.Transport, "has_path", return_value=True),
patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
patch.object(RNS.Transport, "hops_to", return_value=1),
- patch.object(manager, "_wait_for_path", return_value=True),
+ patch.object(manager, "_wait_for_usable_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", return_value=False),
patch(
"meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
return_value=False,
),
+ # Avoid cooldown short-circuit for A so both nodes are probed.
+ patch.object(manager, "_in_failure_cooldown", return_value=False),
):
await manager.check_and_update_propagation_node()
app.set_active_propagation_node.assert_not_called()
app.remove_active_propagation_node.assert_called_once_with(context=context)
+ config.lxmf_preferred_propagation_node_destination_hash.set.assert_called_with(None)
@pytest.mark.asyncio
async def test_check_and_update_propagation_node_noops_without_message_router():
- manager, app, context, config, _database = _make_manager()
+ manager, app, context, config, _database, _store = _make_manager()
context.message_router = None
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
@@ -290,7 +366,7 @@ async def test_auto_propagation_interrupts_sync_when_path_unresponsive():
Even if RNS reports has_path=True, a stale or unresponsive path should be
treated as broken so the manager can look for a working alternative.
"""
- manager, app, context, config, database = _make_manager()
+ manager, app, context, config, database, _store = _make_manager()
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
@@ -306,9 +382,9 @@ async def test_auto_propagation_interrupts_sync_when_path_unresponsive():
with (
patch.object(RNS.Transport, "has_path", return_value=True),
- patch.object(RNS.Transport, "path_is_unresponsive", return_value=True),
+ patch.object(RNS.Transport, "path_is_unresponsive") as mock_unresponsive,
patch.object(RNS.Transport, "hops_to", return_value=1),
- patch.object(manager, "_wait_for_path", return_value=True),
+ patch.object(manager, "_wait_for_usable_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", return_value=True),
patch("meshchatx.src.backend.auto_propagation_manager.asyncio.sleep"),
patch(
@@ -316,6 +392,8 @@ async def test_auto_propagation_interrupts_sync_when_path_unresponsive():
return_value=False,
),
):
+ mock_unresponsive.side_effect = lambda dh: dh == bytes.fromhex(_VALID_HASH_A)
+
await manager.check_and_update_propagation_node()
app.stop_propagation_node_sync.assert_called_once_with(context=context)
@@ -325,6 +403,106 @@ async def test_auto_propagation_interrupts_sync_when_path_unresponsive():
)
+@pytest.mark.asyncio
+async def test_probe_propagation_sync_treats_complete_as_success():
+ """LXMF finishes successful sync at PR_COMPLETE, not only PR_IDLE."""
+ manager, app, context, config, database, _store = _make_manager()
+ router = context.message_router
+ router.propagation_transfer_state = LXMRouter.PR_IDLE
+
+ call_count = [0]
+
+ async def fake_sleep(_):
+ call_count[0] += 1
+ if call_count[0] == 1:
+ router.propagation_transfer_state = LXMRouter.PR_LINK_ESTABLISHING
+ elif call_count[0] == 2:
+ router.propagation_transfer_state = LXMRouter.PR_COMPLETE
+
+ with (
+ patch(
+ "meshchatx.src.backend.auto_propagation_manager.asyncio.sleep",
+ fake_sleep,
+ ),
+ patch.object(RNS.Identity, "recall", return_value=object()),
+ ):
+ result = await manager._probe_propagation_sync(_VALID_HASH_A)
+
+ assert result is True
+
+
+@pytest.mark.asyncio
+async def test_probe_propagation_sync_uses_scarce_message_budget():
+ """Auto-select probes must not pull a full mailbox (Zen scarcity)."""
+ manager, app, context, config, database, _store = _make_manager()
+ router = context.message_router
+ router.propagation_transfer_state = LXMRouter.PR_IDLE
+
+ call_count = [0]
+
+ async def fake_sleep(_):
+ call_count[0] += 1
+ if call_count[0] == 1:
+ router.propagation_transfer_state = LXMRouter.PR_REQUEST_SENT
+ elif call_count[0] == 2:
+ router.propagation_transfer_state = LXMRouter.PR_COMPLETE
+
+ with (
+ patch(
+ "meshchatx.src.backend.auto_propagation_manager.asyncio.sleep",
+ fake_sleep,
+ ),
+ patch.object(RNS.Identity, "recall", return_value=object()),
+ ):
+ result = await manager._probe_propagation_sync(_VALID_HASH_A)
+
+ assert result is True
+ router.request_messages_from_propagation_node.assert_called_once()
+ _args, kwargs = router.request_messages_from_propagation_node.call_args
+ # Prefer kwargs if present, else positional max_messages.
+ if "max_messages" in kwargs:
+ assert kwargs["max_messages"] == 1
+ else:
+ assert _args[1] == 1
+
+
+@pytest.mark.asyncio
+async def test_auto_propagation_caps_probes_per_cycle():
+ """Do not sync-probe every announced peer in one cycle."""
+ from meshchatx.src.backend.auto_propagation_manager import MAX_PROBES_PER_CYCLE
+
+ manager, app, context, config, database, _store = _make_manager()
+ config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
+ config.lxmf_preferred_propagation_node_destination_hash.get.return_value = None
+
+ announces = []
+ for i in range(MAX_PROBES_PER_CYCLE + 3):
+ announces.append(
+ {
+ "destination_hash": f"{i:02x}" * 16,
+ "app_data": _APP_DATA_ENABLED,
+ },
+ )
+ database.announces.get_announces.return_value = announces
+
+ with (
+ patch.object(RNS.Transport, "has_path", return_value=True),
+ patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
+ patch.object(RNS.Transport, "hops_to", return_value=1),
+ patch.object(manager, "_wait_for_usable_path", return_value=True),
+ patch.object(
+ manager, "_probe_propagation_sync", return_value=False
+ ) as mock_probe,
+ patch(
+ "meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
+ return_value=False,
+ ),
+ ):
+ await manager.check_and_update_propagation_node()
+
+ assert mock_probe.call_count == MAX_PROBES_PER_CYCLE
+
+
@pytest.mark.asyncio
async def test_probe_propagation_sync_ignores_stale_state():
"""A stale non-idle state from a previous sync must not cause a false success.
@@ -334,7 +512,7 @@ async def test_probe_propagation_sync_ignores_stale_state():
"""
import time
- manager, app, context, config, database = _make_manager()
+ manager, app, context, config, database, _store = _make_manager()
router = context.message_router
router.propagation_transfer_state = LXMRouter.PR_RECEIVING
@@ -357,6 +535,8 @@ async def test_probe_propagation_sync_ignores_stale_state():
fake_sleep,
),
patch.object(time, "monotonic", fake_monotonic),
+ patch.object(RNS.Identity, "recall", return_value=None),
+ patch.object(RNS.Transport, "request_path"),
):
result = await manager._probe_propagation_sync(_VALID_HASH_A)
@@ -384,3 +564,147 @@ async def test_sync_propagation_nodes_skips_when_active_and_not_forced():
router.request_messages_from_propagation_node.assert_not_called()
mock_stop.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_auto_propagation_keeps_recently_verified_without_probe():
+ """A recently verified preferred node with a good path should not be re-probed."""
+ import json
+ import time
+
+ memory = {
+ _VALID_HASH_A: {
+ "successes": 3,
+ "failures": 0,
+ "consecutive_failures": 0,
+ "last_success_at": int(time.time()),
+ "last_failure_at": 0,
+ "last_hops": 2,
+ },
+ }
+ manager, app, context, config, database, store = _make_manager(
+ memory_raw=json.dumps(memory),
+ )
+
+ config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
+ config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
+ _VALID_HASH_A
+ )
+ context.message_router.get_outbound_propagation_node.return_value = bytes.fromhex(
+ _VALID_HASH_A,
+ )
+
+ announce1 = {
+ "destination_hash": _VALID_HASH_A,
+ "app_data": _APP_DATA_ENABLED,
+ }
+ announce2 = {
+ "destination_hash": _VALID_HASH_B,
+ "app_data": _APP_DATA_ENABLED,
+ }
+ database.announces.get_announces.return_value = [announce1, announce2]
+
+ with (
+ patch.object(RNS.Transport, "has_path", return_value=True),
+ patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
+ patch.object(RNS.Transport, "hops_to") as mock_hops,
+ patch.object(manager, "_wait_for_usable_path") as mock_wait,
+ patch.object(manager, "_probe_propagation_sync") as mock_probe,
+ patch.object(RNS.Transport, "request_path") as mock_request_path,
+ patch(
+ "meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
+ return_value=False,
+ ),
+ ):
+ # B is only one hop better, inside hysteresis, so keep A.
+ mock_hops.side_effect = lambda dh: (
+ 2 if dh == bytes.fromhex(_VALID_HASH_A) else 1
+ )
+ await manager.check_and_update_propagation_node()
+
+ mock_wait.assert_not_called()
+ mock_probe.assert_not_called()
+ app.set_active_propagation_node.assert_not_called()
+ mock_request_path.assert_called()
+ assert MEMORY_CONFIG_KEY in store or _VALID_HASH_A in manager._memory
+
+
+@pytest.mark.asyncio
+async def test_auto_propagation_remembers_good_node_without_announce():
+ """Remembered successful nodes remain candidates when announces age out."""
+ import json
+ import time
+
+ memory = {
+ _VALID_HASH_A: {
+ "successes": 4,
+ "failures": 0,
+ "consecutive_failures": 0,
+ "last_success_at": int(time.time()),
+ "last_failure_at": 0,
+ "last_hops": 1,
+ },
+ }
+ manager, app, context, config, database, _store = _make_manager(
+ memory_raw=json.dumps(memory),
+ )
+ # Force re-verify so the sticky short-circuit does not skip the probe.
+ manager._memory[_VALID_HASH_A]["last_success_at"] = int(time.time()) - (
+ FAILURE_COOLDOWN_SECONDS * 10
+ )
+
+ config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
+ config.lxmf_preferred_propagation_node_destination_hash.get.return_value = None
+ database.announces.get_announces.return_value = []
+
+ with (
+ patch.object(RNS.Transport, "has_path", return_value=True),
+ patch.object(RNS.Transport, "path_is_unresponsive", return_value=False),
+ patch.object(RNS.Transport, "hops_to", return_value=1),
+ patch.object(manager, "_wait_for_usable_path", return_value=True),
+ patch.object(manager, "_probe_propagation_sync", return_value=True),
+ patch(
+ "meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
+ return_value=False,
+ ),
+ ):
+ await manager.check_and_update_propagation_node()
+
+ app.set_active_propagation_node.assert_called_with(
+ _VALID_HASH_A,
+ context=context,
+ )
+ config.lxmf_preferred_propagation_node_destination_hash.set.assert_called_with(
+ _VALID_HASH_A,
+ )
+
+
+@pytest.mark.asyncio
+async def test_wait_for_usable_path_rejects_unresponsive_paths():
+ manager, _app, _context, _config, _database, _store = _make_manager()
+ dest = bytes.fromhex(_VALID_HASH_A)
+
+ with (
+ patch(
+ "meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.prepare_fresh_path_request",
+ return_value="new_path_requested",
+ ),
+ patch.object(RNS.Transport, "has_path", return_value=True),
+ patch.object(RNS.Transport, "path_is_unresponsive", return_value=True),
+ patch(
+ "meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.transport_path_table_entry_is_expired",
+ return_value=False,
+ ),
+ patch(
+ "meshchatx.src.backend.auto_propagation_manager.reticulum_pathfinding.nudge_path_request",
+ ) as mock_nudge,
+ patch("meshchatx.src.backend.auto_propagation_manager.asyncio.sleep"),
+ patch(
+ "meshchatx.src.backend.auto_propagation_manager.time.monotonic",
+ ) as mock_mono,
+ ):
+ mock_mono.side_effect = [0.0, 0.1, 50.0, 50.0]
+ ok = await manager._wait_for_usable_path(dest, timeout=1.0)
+
+ assert ok is False
+ mock_nudge.assert_called()

diff --git a/tests/backend/test_build_meta.py b/tests/backend/test_build_meta.py
new file mode 100644
index 00000000..4bb87844
--- /dev/null
+++ b/tests/backend/test_build_meta.py
@@ -0,0 +1,27 @@
+# SPDX-License-Identifier: 0BSD
+
+from meshchatx.src import build_meta
+
+
+def test_display_version_adds_dev_suffix(monkeypatch):
+ monkeypatch.setattr(build_meta, "IS_DEV_BUILD", True)
+ assert build_meta.display_version("4.8.0") == "4.8.0-dev"
+ assert build_meta.display_version("4.8.0-dev") == "4.8.0-dev"
+
+
+def test_display_version_release(monkeypatch):
+ monkeypatch.setattr(build_meta, "IS_DEV_BUILD", False)
+ assert build_meta.display_version("4.8.0") == "4.8.0"
+
+
+def test_as_dict_includes_commit_fields(monkeypatch):
+ monkeypatch.setattr(build_meta, "GIT_COMMIT", "abcdef0123456789")
+ monkeypatch.setattr(build_meta, "GIT_COMMIT_SHORT", "abcdef0")
+ monkeypatch.setattr(build_meta, "BUILD_CHANNEL", "nightly")
+ monkeypatch.setattr(build_meta, "IS_DEV_BUILD", True)
+ data = build_meta.as_dict("4.8.0")
+ assert data["git_commit"] == "abcdef0123456789"
+ assert data["git_commit_short"] == "abcdef0"
+ assert data["build_channel"] == "nightly"
+ assert data["is_dev_build"] is True
+ assert data["display_version"] == "4.8.0-dev"

diff --git a/tests/backend/test_interface_module_store.py b/tests/backend/test_interface_module_store.py
index 066e8782..71684280 100644
--- a/tests/backend/test_interface_module_store.py
+++ b/tests/backend/test_interface_module_store.py
@@ -66,3 +66,28 @@ def test_install_list_delete_interface_module(tmp_path):
assert not os.path.exists(
os.path.join(str(config_dir), "interfaces", "ExampleInterface.py")
)
+
+
+def test_ensure_bundled_interface_modules_installs_http_interface(tmp_path):
+ from meshchatx.src.backend.interface_module_store import (
+ ensure_bundled_interface_modules,
+ resolve_bundled_interface_module_path,
+ )
+
+ source = resolve_bundled_interface_module_path("HTTPInterface.py")
+ assert source is not None
+ assert os.path.isfile(source)
+
+ config_dir = tmp_path / "reticulum"
+ config_dir.mkdir()
+ written = ensure_bundled_interface_modules(str(config_dir))
+ assert written
+ assert written[0]["type"] == "HTTPInterface"
+ target = config_dir / "interfaces" / "HTTPInterface.py"
+ assert target.is_file()
+ with open(source, "rb") as handle:
+ expected = handle.read()
+ assert target.read_bytes() == expected
+
+ again = ensure_bundled_interface_modules(str(config_dir))
+ assert again == []

diff --git a/tests/backend/test_interface_options.py b/tests/backend/test_interface_options.py
index 3540cd0f..e729312a 100644
--- a/tests/backend/test_interface_options.py
+++ b/tests/backend/test_interface_options.py
@@ -828,3 +828,78 @@ async def test_backbone_listener_fast_flapping_options(temp_dir):
assert saved["fast_flapping_block_time"] == 60
assert saved["fast_flapping_threshold"] == 15
assert saved["fast_flapping_grace"] == 3
+
+
+@pytest.mark.asyncio
+async def test_http_interface_client_persists_tunnel_options(temp_dir):
+ config = ConfigDict({"reticulum": {}, "interfaces": {}})
+
+ async with make_app(temp_dir, config) as handler:
+ payload = {
+ "name": "HttpClient",
+ "type": "HTTPInterface",
+ "mode": "client",
+ "server_url": "https://example.com:8080/",
+ "poll_interval": 0.2,
+ "mtu": 2048,
+ "http_version": 1,
+ "user_agent": "RNS-HTTP-Tunnel/1.0",
+ "tls_verify": True,
+ }
+ response = await handler(make_request(payload))
+ body = json.loads(response.body)
+ assert response.status == 200, body
+ saved = config["interfaces"]["HttpClient"]
+ assert saved["type"] == "HTTPInterface"
+ assert saved["mode"] == "client"
+ assert saved["server_url"] == "https://example.com:8080/"
+ assert saved["poll_interval"] == 0.2
+ assert saved["mtu"] == 2048
+ assert saved["http_version"] == 1
+ assert saved["user_agent"] == "RNS-HTTP-Tunnel/1.0"
+ assert "listen_port" not in saved
+
+
+@pytest.mark.asyncio
+async def test_http_interface_server_persists_listen_options(temp_dir):
+ config = ConfigDict({"reticulum": {}, "interfaces": {}})
+ free_port = _free_port("tcp")
+
+ async with make_app(temp_dir, config) as handler:
+ payload = {
+ "name": "HttpServer",
+ "type": "HTTPInterface",
+ "mode": "server",
+ "listen_host": "127.0.0.1",
+ "listen_port": free_port,
+ "check_user_agent": True,
+ "mtu": 4096,
+ "http_version": 1,
+ "user_agent": "RNS-HTTP-Tunnel/1.0",
+ }
+ response = await handler(make_request(payload))
+ body = json.loads(response.body)
+ assert response.status == 200, body
+ saved = config["interfaces"]["HttpServer"]
+ assert saved["mode"] == "server"
+ assert saved["listen_host"] == "127.0.0.1"
+ assert saved["listen_port"] == free_port
+ assert saved["check_user_agent"] is True
+ assert "server_url" not in saved
+
+
+@pytest.mark.asyncio
+async def test_http_interface_rejects_reticulum_mode_values(temp_dir):
+ config = ConfigDict({"reticulum": {}, "interfaces": {}})
+
+ async with make_app(temp_dir, config) as handler:
+ payload = {
+ "name": "HttpBad",
+ "type": "HTTPInterface",
+ "mode": "gateway",
+ "server_url": "https://example.com/",
+ }
+ response = await handler(make_request(payload))
+ body = json.loads(response.body)
+ assert response.status == 422, body
+ assert "client or server" in body["message"]

diff --git a/tests/frontend/AddInterfaceOptions.test.js b/tests/frontend/AddInterfaceOptions.test.js
index 1f235e97..9cc77de0 100644
--- a/tests/frontend/AddInterfaceOptions.test.js
+++ b/tests/frontend/AddInterfaceOptions.test.js
@@ -398,4 +398,45 @@ describe("AddInterfacePage.vue interface options", () => {
})
);
});
+
+ it("sends HTTPInterface client tunnel payload", async () => {
+ const wrapper = mountPage();
+
+ wrapper.vm.newInterfaceName = "HttpClient";
+ wrapper.vm.newInterfaceType = "HTTPInterface";
+ wrapper.vm.newInterfaceHttpTunnelMode = "client";
+ wrapper.vm.newInterfaceHttpServerUrl = "https://example.com:8080/";
+ wrapper.vm.newInterfaceHttpPollInterval = 0.2;
+ wrapper.vm.newInterfaceHttpMtu = 2048;
+ wrapper.vm.newInterfaceHttpVersion = 1;
+ wrapper.vm.newInterfaceHttpUserAgent = "RNS-HTTP-Tunnel/1.0";
+
+ await wrapper.vm.saveInterface();
+
+ expect(mockAxios.post).toHaveBeenCalledWith(
+ "/api/v1/reticulum/interfaces/add",
+ expect.objectContaining({
+ type: "HTTPInterface",
+ mode: "client",
+ server_url: "https://example.com:8080/",
+ poll_interval: 0.2,
+ mtu: 2048,
+ http_version: 1,
+ user_agent: "RNS-HTTP-Tunnel/1.0",
+ })
+ );
+ });
+
+ it("blocks HTTPInterface client save without server_url", async () => {
+ const wrapper = mountPage();
+
+ wrapper.vm.newInterfaceName = "HttpClient";
+ wrapper.vm.newInterfaceType = "HTTPInterface";
+ wrapper.vm.newInterfaceHttpTunnelMode = "client";
+ wrapper.vm.newInterfaceHttpServerUrl = " ";
+
+ await wrapper.vm.saveInterface();
+
+ expect(mockAxios.post).not.toHaveBeenCalled();
+ });
});

diff --git a/tests/frontend/AppSidebarIdentityAnnounce.test.js b/tests/frontend/AppSidebarIdentityAnnounce.test.js
index a2d1868d..479abaeb 100644
--- a/tests/frontend/AppSidebarIdentityAnnounce.test.js
+++ b/tests/frontend/AppSidebarIdentityAnnounce.test.js
@@ -179,6 +179,33 @@ describe("App.vue sidebar identity label and announce control", () => {
expect(versionLink.attributes("title")).toBe(`v${appPackageVersion}`);
});
+ it("shows -dev and short commit for nightly-style app info", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/app/info") {
+ return Promise.resolve({
+ data: {
+ app_info: {
+ version: appPackageVersion,
+ display_version: `${appPackageVersion}-dev`,
+ is_dev_build: true,
+ git_commit: "abcdef0123456789",
+ git_commit_short: "abcdef0",
+ build_channel: "nightly",
+ tutorial_seen: true,
+ changelog_seen_version: appPackageVersion,
+ },
+ },
+ });
+ }
+ return defaultAxiosImplementation(url);
+ });
+ wrapper = makeMountedApp();
+ await readyShell(wrapper.vm.$router);
+ const versionLink = wrapper.find('[data-testid="sidebar-app-version"]');
+ expect(versionLink.text()).toContain(`v${appPackageVersion}-dev`);
+ expect(versionLink.text()).toContain("abcdef0");
+ });
+
it("falls back to My Identity when display name is empty", async () => {
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/config") {

diff --git a/tests/frontend/TutorialModalConnectBootstrap.test.js b/tests/frontend/TutorialModalConnectBootstrap.test.js
new file mode 100644
index 00000000..aeb85d31
--- /dev/null
+++ b/tests/frontend/TutorialModalConnectBootstrap.test.js
@@ -0,0 +1,400 @@
+/* SPDX-License-Identifier: 0BSD */
+/**
+ * Adversarial / race oracles for tutorial connect + bootstrap flows.
+ * Each case states an invariant and asserts an exact postcondition.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { mount, flushPromises } from "@vue/test-utils";
+import { createRouter, createWebHashHistory } from "vue-router";
+import { createI18n } from "vue-i18n";
+import { createVuetify } from "vuetify";
+import TutorialModal from "../../meshchatx/src/frontend/components/TutorialModal.vue";
+import en from "../../meshchatx/src/frontend/locales/en.json";
+import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
+
+vi.mock("../../meshchatx/src/frontend/js/GlobalState", () => ({
+ default: {
+ config: { theme: "light", language: "en" },
+ hasPendingInterfaceChanges: false,
+ modifiedInterfaceNames: new Set(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ warning: vi.fn(),
+ info: vi.fn(),
+ },
+}));
+
+vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({
+ default: {
+ confirm: vi.fn().mockResolvedValue(true),
+ },
+}));
+
+const axiosMock = { get: vi.fn(), post: vi.fn(), patch: vi.fn() };
+const vuetify = createVuetify();
+const i18n = createI18n({
+ legacy: false,
+ locale: "en",
+ messages: { en },
+});
+
+const dialogStubs = {
+ LanguageSelector: true,
+ MaterialDesignIcon: true,
+ Toggle: true,
+ TutorialPrivacyStep: true,
+ VIcon: { template: '<span class="v-icon-stub"/>' },
+ VProgressCircular: { template: '<span class="progress-stub"/>' },
+};
+
+function tcpCommunity(n) {
+ return Array.from({ length: n }, (_, i) => ({
+ name: `Node ${i + 1}`,
+ type: "TCPClientInterface",
+ target_host: `10.0.0.${i + 1}`,
+ target_port: 4242 + i,
+ description: "community tcp",
+ }));
+}
+
+function apiHandlers(communityInterfaces = []) {
+ return (url) => {
+ if (url === "/api/v1/app/info") {
+ return Promise.resolve({ data: { app_info: { migration: { show_choice: false } } } });
+ }
+ if (url === "/api/v1/reticulum/discovery") {
+ return Promise.resolve({ data: { discovery: { default_bootstrap_only: false } } });
+ }
+ if (url === "/api/v1/community-interfaces") {
+ return Promise.resolve({ data: { interfaces: communityInterfaces } });
+ }
+ if (url === "/api/v1/reticulum/discovered-interfaces") {
+ return Promise.resolve({ data: { interfaces: [], active: [] } });
+ }
+ if (url === "/api/v1/config") {
+ return Promise.resolve({ data: { config: { display_name: "Anonymous Peer" } } });
+ }
+ if (url === "/api/v1/identities") {
+ return Promise.resolve({
+ data: {
+ identities: [{ hash: "default_identity", display_name: "Anonymous Peer", is_current: true }],
+ },
+ });
+ }
+ return Promise.resolve({ data: {} });
+ };
+}
+
+async function mountTutorial(communityInterfaces = tcpCommunity(6)) {
+ axiosMock.get.mockImplementation(apiHandlers(communityInterfaces));
+ axiosMock.post.mockResolvedValue({ data: { message: "ok" } });
+ axiosMock.patch.mockResolvedValue({ data: { message: "ok" } });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+ await wrapper.vm.show();
+ await flushPromises();
+ return wrapper;
+}
+
+describe("TutorialModal connect/bootstrap adversarial oracles", () => {
+ beforeEach(() => {
+ window.api = axiosMock;
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("H1: footer must not expose Finish Setup on connect or bootstrap steps", async () => {
+ const wrapper = await mountTutorial();
+ for (const step of [3, 4]) {
+ wrapper.vm.currentStep = step;
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.showFooterContinue).toBe(false);
+ expect(wrapper.vm.currentStep === wrapper.vm.totalSteps).toBe(false);
+ const finishButtons = wrapper.findAll("button").filter((b) => b.text().includes(en.tutorial.finish_setup));
+ expect(finishButtons.length, `finish visible on step ${step}`).toBe(0);
+ }
+ wrapper.unmount();
+ });
+
+ it("H2: nextStep with recommended mode must enter bootstrap step 4 not skip to 5", async () => {
+ const wrapper = await mountTutorial();
+ wrapper.vm.currentStep = 3;
+ wrapper.vm.connectionMode = "recommended";
+ wrapper.vm.nextStep();
+ expect(wrapper.vm.currentStep).toBe(4);
+ wrapper.unmount();
+ });
+
+ it("H3: previousStep from propagation after recommended returns to bootstrap step 4", async () => {
+ const wrapper = await mountTutorial();
+ wrapper.vm.connectionMode = "recommended";
+ wrapper.vm.currentStep = 5;
+ wrapper.vm.previousStep();
+ expect(wrapper.vm.currentStep).toBe(4);
+ wrapper.unmount();
+ });
+
+ it("H4: concurrent auto pickRandom must not interleave (second call no-ops while busy)", async () => {
+ const wrapper = await mountTutorial(tcpCommunity(8));
+ wrapper.vm.currentStep = 4;
+ wrapper.vm.connectionMode = "discovery";
+ wrapper.vm.communityInterfaces = tcpCommunity(8);
+ const shuffleSpy = vi.spyOn(wrapper.vm, "shuffleArrayInPlace");
+
+ const p1 = wrapper.vm.pickRandomTcpBootstraps({ silent: true, auto: true, count: 3 });
+ expect(wrapper.vm.pickingRandomBootstraps).toBe(true);
+ const p2 = wrapper.vm.pickRandomTcpBootstraps({ silent: true, auto: true, count: 3 });
+ await Promise.all([p1, p2]);
+ await flushPromises();
+
+ expect(shuffleSpy).toHaveBeenCalledTimes(1);
+ expect(wrapper.vm.selectedBootstrapKeys.length).toBe(3);
+ expect(wrapper.vm.pickingRandomBootstraps).toBe(false);
+ wrapper.unmount();
+ });
+
+ it("H5: useDiscoveryMode resets auto-pick latch so a second visit can pick again", async () => {
+ const wrapper = await mountTutorial(tcpCommunity(5));
+ wrapper.vm.currentStep = 3;
+ wrapper.vm.bootstrapAutoPickDone = true;
+ wrapper.vm.selectedBootstrapKeys = [];
+ wrapper.vm.communityInterfaces = tcpCommunity(5);
+
+ await wrapper.vm.useDiscoveryMode();
+ await flushPromises();
+ await wrapper.vm.$nextTick();
+ await flushPromises();
+
+ expect(wrapper.vm.currentStep).toBe(4);
+ expect(wrapper.vm.connectionMode).toBe("discovery");
+ expect(wrapper.vm.pickingRandomBootstraps).toBe(false);
+ expect(wrapper.vm.selectedBootstrapKeys.length).toBe(3);
+ expect(wrapper.vm.bootstrapAutoPickDone).toBe(true);
+ wrapper.unmount();
+ });
+
+ it("H5b: watcher must not mark auto-pick done when pick is already in flight", async () => {
+ const wrapper = await mountTutorial(tcpCommunity(5));
+ wrapper.vm.currentStep = 4;
+ wrapper.vm.connectionMode = "discovery";
+ wrapper.vm.bootstrapAutoPickDone = false;
+ wrapper.vm.selectedBootstrapKeys = [];
+ wrapper.vm.communityInterfaces = tcpCommunity(5);
+ wrapper.vm.pickingRandomBootstraps = true;
+
+ await wrapper.vm.maybeAutoPickBootstrapTcp();
+ expect(wrapper.vm.bootstrapAutoPickDone).toBe(false);
+ expect(wrapper.vm.selectedBootstrapKeys.length).toBe(0);
+ wrapper.unmount();
+ });
+
+ it("H6: useRecommendedMode adds AutoInterface, enables discovery, picks exactly 3 TCP", async () => {
+ const wrapper = await mountTutorial(tcpCommunity(7));
+ wrapper.vm.currentStep = 3;
+
+ await wrapper.vm.useRecommendedMode();
+ await flushPromises();
+
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/reticulum/interfaces/add", {
+ name: "Local Network",
+ type: "AutoInterface",
+ enabled: true,
+ });
+ expect(axiosMock.patch).toHaveBeenCalledWith(
+ "/api/v1/reticulum/discovery",
+ expect.objectContaining({
+ discover_interfaces: true,
+ autoconnect_discovered_interfaces: 3,
+ })
+ );
+ expect(wrapper.vm.currentStep).toBe(4);
+ expect(wrapper.vm.connectionMode).toBe("recommended");
+ expect(wrapper.vm.selectedBootstrapKeys.length).toBe(3);
+ expect(wrapper.vm.selectedBootstrapKeys.every((k) => k.startsWith("comm:"))).toBe(true);
+ expect(wrapper.vm.addingRecommended).toBe(false);
+ wrapper.unmount();
+ });
+
+ it("H7: connection mode buttons stay locked while discovery is in flight", async () => {
+ let resolvePatch;
+ axiosMock.get.mockImplementation(apiHandlers(tcpCommunity(4)));
+ axiosMock.patch.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ resolvePatch = resolve;
+ })
+ );
+ axiosMock.post.mockResolvedValue({ data: {} });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+ await wrapper.vm.show();
+ await flushPromises();
+ wrapper.vm.currentStep = 3;
+
+ const pending = wrapper.vm.useDiscoveryMode();
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.connectionSetupBusy).toBe(true);
+ expect(wrapper.vm.tutorialNavBusy).toBe(true);
+
+ // Adversarial second click must not start another discovery patch
+ const patchCallsBefore = axiosMock.patch.mock.calls.length;
+ await wrapper.vm.useDiscoveryMode();
+ await wrapper.vm.useRecommendedMode();
+ await wrapper.vm.useLocalMode();
+ expect(axiosMock.patch.mock.calls.length).toBe(patchCallsBefore);
+
+ resolvePatch({ data: { message: "ok" } });
+ await pending;
+ await flushPromises();
+ expect(wrapper.vm.connectionSetupBusy).toBe(false);
+ wrapper.unmount();
+ });
+
+ it("H8: skip/confirm bootstraps are ignored while random pick is busy", async () => {
+ const wrapper = await mountTutorial(tcpCommunity(5));
+ wrapper.vm.currentStep = 4;
+ wrapper.vm.connectionMode = "discovery";
+ wrapper.vm.pickingRandomBootstraps = true;
+ wrapper.vm.selectedBootstrapKeys = ["comm:Node 1"];
+
+ wrapper.vm.skipBootstraps();
+ expect(wrapper.vm.currentStep).toBe(4);
+
+ await wrapper.vm.confirmBootstraps();
+ expect(wrapper.vm.currentStep).toBe(4);
+ expect(axiosMock.post).not.toHaveBeenCalledWith(
+ "/api/v1/reticulum/interfaces/add",
+ expect.objectContaining({ name: "Node 1" })
+ );
+ wrapper.unmount();
+ });
+
+ it("H9: yggdrasil / ygg-labelled community presets are excluded from random pick", async () => {
+ const mixed = [
+ {
+ name: "Yggdrasil Bridge",
+ type: "TCPClientInterface",
+ target_host: "1.1.1.1",
+ target_port: 4242,
+ description: "ygg only",
+ },
+ {
+ name: "Normal A",
+ type: "TCPClientInterface",
+ target_host: "2.2.2.2",
+ target_port: 4242,
+ },
+ {
+ name: "Normal B",
+ type: "TCPClientInterface",
+ target_host: "3.3.3.3",
+ target_port: 4242,
+ },
+ {
+ name: "Normal C",
+ type: "TCPClientInterface",
+ target_host: "4.4.4.4",
+ target_port: 4242,
+ },
+ {
+ name: "Normal D",
+ type: "TCPClientInterface",
+ target_host: "5.5.5.5",
+ target_port: 4242,
+ },
+ ];
+ const wrapper = await mountTutorial(mixed);
+ wrapper.vm.communityInterfaces = mixed;
+ await wrapper.vm.pickRandomTcpBootstraps({ silent: true, auto: true, count: 3 });
+ expect(wrapper.vm.selectedBootstrapKeys).not.toContain("comm:Yggdrasil Bridge");
+ expect(wrapper.vm.selectedBootstrapKeys.length).toBe(3);
+ wrapper.unmount();
+ });
+
+ it("H10: random pick prefers at most available eligible nodes and never selects hostless entries", async () => {
+ const sparse = [
+ { name: "Good1", type: "TCPClientInterface", target_host: "8.8.8.8", target_port: 4242 },
+ { name: "NoHost", type: "TCPClientInterface", target_host: "", target_port: 4242 },
+ { name: "UDP", type: "UDPInterface", target_host: "9.9.9.9", target_port: 4242 },
+ { name: "Good2", type: "TCPClientInterface", target_host: "8.8.4.4", target_port: 4242 },
+ ];
+ const wrapper = await mountTutorial(sparse);
+ wrapper.vm.communityInterfaces = sparse;
+ await wrapper.vm.pickRandomTcpBootstraps({ count: 99 });
+ expect(wrapper.vm.selectedBootstrapKeys.sort()).toEqual(["comm:Good1", "comm:Good2"].sort());
+ wrapper.unmount();
+ });
+
+ it("H11: useRecommendedMode failure after AutoInterface add does not advance step", async () => {
+ axiosMock.get.mockImplementation(apiHandlers(tcpCommunity(3)));
+ axiosMock.post.mockResolvedValue({ data: { message: "added" } });
+ axiosMock.patch.mockRejectedValue({ response: { data: { message: "discovery failed" } } });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+ await wrapper.vm.show();
+ await flushPromises();
+ wrapper.vm.currentStep = 3;
+
+ await wrapper.vm.useRecommendedMode();
+ await flushPromises();
+
+ expect(wrapper.vm.currentStep).toBe(3);
+ expect(wrapper.vm.connectionMode).toBeNull();
+ expect(wrapper.vm.addingRecommended).toBe(false);
+ expect(ToastUtils.error).toHaveBeenCalled();
+ wrapper.unmount();
+ });
+
+ it("H12: theme/language controls are in-flow (not absolute overlay) so titles are not covered", async () => {
+ const wrapper = await mountTutorial();
+ expect(wrapper.find(".absolute.top-4.left-4").exists()).toBe(false);
+ expect(wrapper.find(".absolute.top-4.right-4").exists()).toBe(false);
+ wrapper.unmount();
+ });
+
+ it("H13: double finishTutorial is race-safe while connect busy", async () => {
+ const wrapper = await mountTutorial();
+ wrapper.vm.currentStep = 3;
+ wrapper.vm.savingDiscovery = true;
+ await wrapper.vm.finishTutorial();
+ expect(wrapper.vm.visible).toBe(true);
+ expect(wrapper.vm.finishingTutorial).toBe(false);
+ wrapper.unmount();
+ });
+});

diff --git a/tests/frontend/bakeBuildMeta.test.js b/tests/frontend/bakeBuildMeta.test.js
new file mode 100644
index 00000000..d2ba3d14
--- /dev/null
+++ b/tests/frontend/bakeBuildMeta.test.js
@@ -0,0 +1,111 @@
+/* SPDX-License-Identifier: 0BSD */
+/**
+ * Channel / commit bake oracles for scripts/bake_build_meta.js
+ */
+import { describe, it, expect, afterEach } from "vitest";
+import { spawnSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
+const bakeScript = path.join(repoRoot, "scripts", "bake_build_meta.js");
+const bakedPath = path.join(repoRoot, "meshchatx", "src", "_build_meta_baked.py");
+
+function readBaked() {
+ const text = fs.readFileSync(bakedPath, "utf8");
+ const commit = (text.match(/^GIT_COMMIT = "(.*)"$/m) || [])[1] ?? "";
+ const short = (text.match(/^GIT_COMMIT_SHORT = "(.*)"$/m) || [])[1] ?? "";
+ const channel = (text.match(/^BUILD_CHANNEL = "(.*)"$/m) || [])[1] ?? "";
+ const isDev = /^IS_DEV_BUILD = True$/m.test(text);
+ return { commit, short, channel, isDev };
+}
+
+function runBake(envOverrides) {
+ const env = { ...process.env, ...envOverrides };
+ for (const key of Object.keys(envOverrides)) {
+ if (envOverrides[key] === "" || envOverrides[key] == null) {
+ delete env[key];
+ }
+ }
+ const result = spawnSync(process.execPath, [bakeScript], {
+ cwd: repoRoot,
+ env,
+ encoding: "utf8",
+ });
+ expect(result.status, result.stderr || result.stdout).toBe(0);
+ return readBaked();
+}
+
+describe("bake_build_meta channel oracles", () => {
+ let previous = null;
+
+ afterEach(() => {
+ if (previous != null) {
+ fs.writeFileSync(bakedPath, previous, "utf8");
+ previous = null;
+ } else if (fs.existsSync(bakedPath)) {
+ // leave whatever last bake wrote; file is gitignored
+ }
+ });
+
+ function snapshot() {
+ if (fs.existsSync(bakedPath)) {
+ previous = fs.readFileSync(bakedPath, "utf8");
+ }
+ }
+
+ it("nightly ref bakes is_dev with short sha", () => {
+ snapshot();
+ const meta = runBake({
+ MESHCHATX_BUILD_CHANNEL: "",
+ GITHUB_REF_NAME: "nightly-2026.07.24-abcdef0",
+ GITHUB_SHA: "abcdef0123456789aaaa",
+ MESHCHATX_GIT_COMMIT: "",
+ GIT_COMMIT: "",
+ });
+ expect(meta.channel).toBe("nightly");
+ expect(meta.isDev).toBe(true);
+ expect(meta.short).toBe("abcdef0");
+ expect(meta.commit.startsWith("abcdef0")).toBe(true);
+ });
+
+ it("release tag clears is_dev", () => {
+ snapshot();
+ const meta = runBake({
+ MESHCHATX_BUILD_CHANNEL: "",
+ GITHUB_REF_NAME: "v4.8.0",
+ GITHUB_SHA: "deadbeefcafebabe",
+ MESHCHATX_GIT_COMMIT: "",
+ GIT_COMMIT: "",
+ });
+ expect(meta.channel).toBe("release");
+ expect(meta.isDev).toBe(false);
+ expect(meta.short).toBe("deadbee");
+ });
+
+ it("explicit MESHCHATX_BUILD_CHANNEL=release wins over nightly ref name", () => {
+ snapshot();
+ const meta = runBake({
+ MESHCHATX_BUILD_CHANNEL: "release",
+ GITHUB_REF_NAME: "nightly-2026.07.24-abcdef0",
+ MESHCHATX_GIT_COMMIT: "1234567890abcdef",
+ });
+ expect(meta.channel).toBe("release");
+ expect(meta.isDev).toBe(false);
+ expect(meta.short).toBe("1234567");
+ });
+
+ it("local/default channel is treated as dev", () => {
+ snapshot();
+ const meta = runBake({
+ MESHCHATX_BUILD_CHANNEL: "local",
+ GITHUB_REF_NAME: "",
+ GITHUB_REF: "",
+ MESHCHATX_GIT_COMMIT: "feedface00",
+ });
+ expect(meta.channel).toBe("local");
+ expect(meta.isDev).toBe(true);
+ expect(meta.short).toBe("feedfac");
+ });
+});

diff --git a/tests/frontend/behaviorContracts.test.js b/tests/frontend/behaviorContracts.test.js
index 5630e1da..9c848656 100644
--- a/tests/frontend/behaviorContracts.test.js
+++ b/tests/frontend/behaviorContracts.test.js
@@ -180,10 +180,19 @@ describe("behavior contracts: Android Chaquopy Python sync", () => {
expect(gradle).toMatch(/syncMeshchatPython/);
expect(gradle).toMatch(/syncLxmfyPython/);
expect(gradle).toMatch(/syncRnsFilesyncPython/);
+ expect(gradle).toContain("httpx[http2]==0.28.1");
+ expect(gradle).toContain("httpx-0.28.1-");
+ const wheelScript = readSource("scripts/build-android-wheels-local.sh");
+ expect(wheelScript).toContain("httpx[http2]");
+ expect(wheelScript).toContain("HTTPX_VERSION");
const lxmfyInit = readSource("vendor/lxmfy/lxmfy/__init__.py");
expect(lxmfyInit.length).toBeGreaterThan(0);
const filesyncInit = readSource("vendor/rns_filesync/rns_filesync/__init__.py");
expect(filesyncInit.length).toBeGreaterThan(0);
+ const httpIface = readSource("vendor/rns_over_http/HTTPInterface.py");
+ expect(httpIface).toContain("interface_class");
+ const packagedHttp = readSource("meshchatx/src/backend/data/interfaces/HTTPInterface.py");
+ expect(packagedHttp).toContain("interface_class");
});
it("Android wrapper clears stale storage lock before main()", () => {

diff --git a/uv.lock b/uv.lock
index c40fb492..6e6b2b60 100644
--- a/uv.lock
+++ b/uv.lock
@@ -177,6 +177,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
+[[package]]
+name = "anyio"
+version = "4.14.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
+]
+
[[package]]
name = "attrs"
version = "26.1.0"
@@ -406,6 +419,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/29/e257a381d494615348c7266fc173a36edce142533a5befe3c0967fd45ab4/cbor2-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:f0bc04543c562bd0b35fc79a29528017fe63104757c1b421d8c1ddfbe6761eca", size = 290270, upload-time = "2026-05-14T10:57:40.597Z" },
]
+[[package]]
+name = "certifi"
+version = "2026.7.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
+]
+
[[package]]
name = "cffi"
version = "2.0.0"
@@ -930,6 +952,79 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
]
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "h2"
+version = "4.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "hpack" },
+ { name = "hyperframe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/30/d4/a7d6fb3f58be99d65cbf2d3f766896217a2921d0f3ab10711c45dc1519ee/h2-4.4.0.tar.gz", hash = "sha256:46b551bdcdc7e83cf5c04d0bf93badb8a939bd2287d9fee1abb23a445b9e0580", size = 2156691, upload-time = "2026-07-23T19:14:19.442Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f6/df/5b14a118322d6097cb9bb30ec6bacad268e546a8ecfcb1f6d0de618dac2f/h2-4.4.0-py3-none-any.whl", hash = "sha256:6acffe1aeab79098d7eb0f8385c1add11f2c7a94815f6fa2b7060eeddee3d87c", size = 62368, upload-time = "2026-07-23T19:14:16.143Z" },
+]
+
+[[package]]
+name = "hpack"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[package.optional-dependencies]
+http2 = [
+ { name = "h2" },
+]
+
+[[package]]
+name = "hyperframe"
+version = "6.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
+]
+
[[package]]
name = "hypothesis"
version = "6.152.4"
@@ -1915,6 +2010,7 @@ dependencies = [
{ name = "bleak" },
{ name = "cbor2" },
{ name = "cryptography" },
+ { name = "httpx", extra = ["http2"] },
{ name = "lxmf" },
{ name = "lxst" },
{ name = "miniaudio" },
@@ -1951,6 +2047,7 @@ requires-dist = [
{ name = "bleak", specifier = ">=3.0.2,<4.0" },
{ name = "cbor2", specifier = ">=6.1.1" },
{ name = "cryptography", specifier = ">=49.0.0,<50.0.0" },
+ { name = "httpx", extras = ["http2"], specifier = ">=0.28.1,<1.0" },
{ name = "lxmf", specifier = ">=1.1.0" },
{ name = "lxst", specifier = ">=0.5.0" },
{ name = "miniaudio", specifier = ">=1.70,<2.0" },

diff --git a/vendor/README.txt b/vendor/README.txt
index 15f6c650..f83ac526 100644
--- a/vendor/README.txt
+++ b/vendor/README.txt
@@ -15,3 +15,13 @@ rns_filesync/
Declared version (pyproject): see vendor/rns_filesync/pyproject.toml
Update: clone default branch, replace vendor/rns_filesync (omit .git), align vendor/README
commit above, regenerate THIRD_PARTY_NOTICES if needed.
+
+rns_over_http/
+ Upstream: https://github.com/Quad4-Software/RNS-over-HTTP
+ Bundled revision: 530aca499fb7442910f691629670f6f9f4b67221
+ Declared version (pyproject): see vendor/rns_over_http/pyproject.toml
+ Update: clone default branch, replace vendor/rns_over_http (omit .git), copy
+ HTTPInterface.py and LICENSE into meshchatx/src/backend/data/interfaces/
+ (as HTTPInterface.py and HTTPInterface.LICENSE), align vendor/README commit above,
+ regenerate THIRD_PARTY_NOTICES if needed.
+ Runtime: MeshChatX installs HTTPInterface.py into Reticulum interfacepath on startup.

diff --git a/vendor/rns_over_http/.gitignore b/vendor/rns_over_http/.gitignore
new file mode 100644
index 00000000..680409f3
--- /dev/null
+++ b/vendor/rns_over_http/.gitignore
@@ -0,0 +1,7 @@
+__pycache__
+.venv
+.pytest_cache
+.hypothesis
+.ruff_cache
+dist/
+*.egg-info/

diff --git a/vendor/rns_over_http/HTTPInterface.py b/vendor/rns_over_http/HTTPInterface.py
new file mode 100755
index 00000000..9ad6dd0b
--- /dev/null
+++ b/vendor/rns_over_http/HTTPInterface.py
@@ -0,0 +1,894 @@
+import asyncio
+import os
+import socket
+import ssl
+import threading
+from collections import deque
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from queue import Empty, Queue
+from socketserver import ThreadingMixIn
+from urllib.parse import urlparse
+
+import httpx
+import RNS
+from RNS.Interfaces.Interface import Interface
+
+
+class HDLC:
+ """Pipe-compatible simplified HDLC framing for HTTP bodies.
+
+ Same FLAG/ESC scheme as RNS PipeInterface so multiple packets can
+ share one HTTP request or response without losing boundaries.
+ """
+
+ FLAG = 0x7E
+ ESC = 0x7D
+ ESC_MASK = 0x20
+
+ @staticmethod
+ def escape(data):
+ data = data.replace(
+ bytes([HDLC.ESC]),
+ bytes([HDLC.ESC, HDLC.ESC ^ HDLC.ESC_MASK]),
+ )
+ data = data.replace(
+ bytes([HDLC.FLAG]),
+ bytes([HDLC.ESC, HDLC.FLAG ^ HDLC.ESC_MASK]),
+ )
+ return data
+
+ @staticmethod
+ def frame(packet):
+ return bytes([HDLC.FLAG]) + HDLC.escape(packet) + bytes([HDLC.FLAG])
+
+ @staticmethod
+ def deframe(buffer, max_frame_len):
+ """Return (packets, remainder) from an HDLC byte buffer."""
+ packets = []
+ in_frame = False
+ escape = False
+ data_buffer = bytearray()
+ i = 0
+ last_complete = 0
+
+ while i < len(buffer):
+ byte = buffer[i]
+ i += 1
+
+ if in_frame and byte == HDLC.FLAG:
+ packets.append(bytes(data_buffer))
+ in_frame = False
+ escape = False
+ data_buffer = bytearray()
+ last_complete = i
+ elif byte == HDLC.FLAG:
+ in_frame = True
+ escape = False
+ data_buffer = bytearray()
+ elif in_frame and len(data_buffer) < max_frame_len:
+ if byte == HDLC.ESC:
+ escape = True
+ else:
+ if escape:
+ if byte == HDLC.FLAG ^ HDLC.ESC_MASK:
+ byte = HDLC.FLAG
+ elif byte == HDLC.ESC ^ HDLC.ESC_MASK:
+ byte = HDLC.ESC
+ escape = False
+ data_buffer.append(byte)
+
+ remainder = buffer[last_complete:] if in_frame else b""
+ return packets, remainder
+
+
+class _H3Session:
+ """Persistent HTTP/3 client over one QUIC connection."""
+
+ def __init__(self, server_url, user_agent, verify=True, ca_certs=None):
+ from aioquic.asyncio import connect
+ from aioquic.asyncio.protocol import QuicConnectionProtocol
+ from aioquic.h3.connection import H3_ALPN, H3Connection
+ from aioquic.h3.events import DataReceived, HeadersReceived
+ from aioquic.quic.configuration import QuicConfiguration
+ from aioquic.quic.events import QuicEvent
+
+ parsed = urlparse(server_url)
+ if parsed.scheme != "https":
+ raise ValueError("HTTP/3 requires an https:// server_url")
+
+ self._host = parsed.hostname
+ self._port = parsed.port or 443
+ self._path = parsed.path or "/"
+ if parsed.query:
+ self._path += "?" + parsed.query
+ self._authority = parsed.netloc
+ self._user_agent = user_agent
+ self._closed = False
+ self._request_count = 0
+
+ configuration = QuicConfiguration(is_client=True, alpn_protocols=H3_ALPN)
+ if not verify:
+ configuration.verify_mode = ssl.CERT_NONE
+ elif ca_certs:
+ configuration.load_verify_locations(ca_certs)
+
+ class _Client(QuicConnectionProtocol):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._http = H3Connection(self._quic)
+ self._request_events = {}
+ self._request_waiter = {}
+
+ def http_event_received(self, event):
+ if isinstance(event, (HeadersReceived, DataReceived)):
+ stream_id = event.stream_id
+ if stream_id in self._request_events:
+ self._request_events[stream_id].append(event)
+ if event.stream_ended:
+ waiter = self._request_waiter.pop(stream_id)
+ waiter.set_result(self._request_events.pop(stream_id))
+
+ def quic_event_received(self, event: QuicEvent):
+ for http_event in self._http.handle_event(event):
+ self.http_event_received(http_event)
+
+ async def post_bytes(self, path, authority, user_agent, data):
+ stream_id = self._quic.get_next_available_stream_id()
+ headers = [
+ (b":method", b"POST"),
+ (b":scheme", b"https"),
+ (b":authority", authority.encode()),
+ (b":path", path.encode()),
+ (b"user-agent", user_agent.encode()),
+ (b"content-type", b"application/octet-stream"),
+ (b"content-length", str(len(data)).encode()),
+ ]
+ self._http.send_headers(
+ stream_id=stream_id,
+ headers=headers,
+ end_stream=not data,
+ )
+ if data:
+ self._http.send_data(stream_id=stream_id, data=data, end_stream=True)
+
+ waiter = self._loop.create_future()
+ self._request_events[stream_id] = deque()
+ self._request_waiter[stream_id] = waiter
+ self.transmit()
+ events = await asyncio.shield(waiter)
+
+ status = 0
+ body = b""
+ for event in events:
+ if isinstance(event, HeadersReceived):
+ for key, value in event.headers:
+ if key == b":status":
+ status = int(value.decode())
+ elif isinstance(event, DataReceived):
+ body += event.data
+ return status, body
+
+ self._Client = _Client
+ self._connect = connect
+ self._configuration = configuration
+ self._loop = asyncio.new_event_loop()
+ self._loop_thread = threading.Thread(
+ target=self._run_loop,
+ name="h3-client-loop",
+ daemon=True,
+ )
+ self._ready = threading.Event()
+ self._error = None
+ self._client = None
+ self._cm = None
+ self._loop_thread.start()
+ if not self._ready.wait(timeout=30):
+ raise TimeoutError("HTTP/3 client failed to connect")
+ if self._error is not None:
+ raise self._error
+
+ def _run_loop(self):
+ asyncio.set_event_loop(self._loop)
+ try:
+ self._loop.run_until_complete(self._open())
+ self._ready.set()
+ self._loop.run_forever()
+ except Exception as exc:
+ self._error = exc
+ self._ready.set()
+
+ async def _open(self):
+ self._cm = self._connect(
+ self._host,
+ self._port,
+ configuration=self._configuration,
+ create_protocol=self._Client,
+ )
+ self._client = await self._cm.__aenter__()
+
+ def post(self, data, timeout=10.0):
+ if self._closed:
+ raise RuntimeError("HTTP/3 session is closed")
+
+ async def _do_post():
+ status, body = await self._client.post_bytes(
+ self._path,
+ self._authority,
+ self._user_agent,
+ data,
+ )
+ self._request_count += 1
+ if status >= 400:
+ raise RuntimeError(f"HTTP/3 status {status}")
+ return body
+
+ fut = asyncio.run_coroutine_threadsafe(_do_post(), self._loop)
+ return fut.result(timeout=timeout)
+
+ def close(self):
+ if self._closed:
+ return
+ self._closed = True
+
+ async def _close():
+ if self._cm is not None:
+ await self._cm.__aexit__(None, None, None)
+
+ try:
+ fut = asyncio.run_coroutine_threadsafe(_close(), self._loop)
+ fut.result(timeout=5)
+ except Exception:
+ pass
+ self._loop.call_soon_threadsafe(self._loop.stop)
+ self._loop_thread.join(timeout=5)
+
+
+class HTTPTunnelInterface(Interface):
+ """HTTP Tunnel Interface for Reticulum.
+
+ Bidirectional RNS transport over HTTP POST with pipe-compatible HDLC
+ framing. Supports HTTP/1.1 (default), HTTP/2, and HTTP/3.
+
+ Engineering defaults:
+ - http_version=1 uses a stdlib HTTP/1.1 server and httpx client.
+ Works over cleartext http:// and is ideal behind Caddy/nginx.
+ - http_version=2 requires TLS and uses Hypercorn (h2) + httpx(http2).
+ - http_version=3 requires TLS and uses Hypercorn QUIC + a persistent
+ aioquic HTTP/3 client session.
+
+ Configuration highlights:
+ http_version: 1, 2, or 3 (default: 1)
+ tls_certfile / tls_keyfile: required for versions 2 and 3 on server
+ tls_verify: verify TLS certs on client (default: true)
+ tls_ca_certs: optional CA bundle path for client verify
+ server_url: use https:// for versions 2 and 3
+ """
+
+ DEFAULT_IFAC_SIZE = 16
+ BITRATE_GUESS = 10_000_000
+ AUTOCONFIGURE_MTU = True
+
+ DEFAULT_MTU = 4096
+ TUNNEL_USER_AGENT = "RNS-HTTP-Tunnel/1.0"
+ DEFAULT_POLL_INTERVAL = 0.1
+ DEFAULT_POOL_CONNECTIONS = 1
+ DEFAULT_POOL_MAXSIZE = 1
+ DEFAULT_KEEPALIVE_TIMEOUT = 60
+ DEFAULT_HTTP_VERSION = 1
+
+ def __init__(self, owner, configuration):
+ super().__init__()
+
+ ifconf = Interface.get_config_obj(configuration)
+
+ self.name = ifconf["name"]
+
+ mode = str(ifconf["mode"]).lower() if "mode" in ifconf else "client"
+ listen_host = ifconf["listen_host"] if "listen_host" in ifconf else "0.0.0.0"
+ listen_port = int(ifconf["listen_port"]) if "listen_port" in ifconf else 8080
+ server_url = ifconf["server_url"] if "server_url" in ifconf else None
+ poll_interval = (
+ float(ifconf["poll_interval"])
+ if "poll_interval" in ifconf
+ else self.DEFAULT_POLL_INTERVAL
+ )
+ check_user_agent = (
+ ifconf.as_bool("check_user_agent") if "check_user_agent" in ifconf else True
+ )
+ user_agent = (
+ str(ifconf["user_agent"])
+ if "user_agent" in ifconf
+ else self.TUNNEL_USER_AGENT
+ )
+ mtu = int(ifconf["mtu"]) if "mtu" in ifconf else self.DEFAULT_MTU
+ serve_html_page = (
+ ifconf.as_bool("serve_html_page") if "serve_html_page" in ifconf else False
+ )
+ html_file_path = (
+ ifconf["html_file_path"] if "html_file_path" in ifconf else None
+ )
+ pool_connections = (
+ int(ifconf["pool_connections"])
+ if "pool_connections" in ifconf
+ else self.DEFAULT_POOL_CONNECTIONS
+ )
+ pool_maxsize = (
+ int(ifconf["pool_maxsize"])
+ if "pool_maxsize" in ifconf
+ else self.DEFAULT_POOL_MAXSIZE
+ )
+ keepalive_timeout = (
+ int(ifconf["keepalive_timeout"])
+ if "keepalive_timeout" in ifconf
+ else self.DEFAULT_KEEPALIVE_TIMEOUT
+ )
+ http_version = (
+ int(ifconf["http_version"])
+ if "http_version" in ifconf
+ else self.DEFAULT_HTTP_VERSION
+ )
+ tls_certfile = ifconf["tls_certfile"] if "tls_certfile" in ifconf else None
+ tls_keyfile = ifconf["tls_keyfile"] if "tls_keyfile" in ifconf else None
+ tls_verify = (
+ ifconf.as_bool("tls_verify") if "tls_verify" in ifconf else True
+ )
+ tls_ca_certs = ifconf["tls_ca_certs"] if "tls_ca_certs" in ifconf else None
+
+ self.mode = mode
+ self.http_version = http_version
+
+ if mode not in ["client", "server"]:
+ raise ValueError(
+ f"Invalid mode '{mode}' for {self}. Must be 'client' or 'server'",
+ )
+
+ if http_version not in (1, 2, 3):
+ raise ValueError(
+ f"Invalid http_version '{http_version}' for {self}. Must be 1, 2, or 3",
+ )
+
+ if mode == "client" and server_url is None:
+ raise ValueError(f"No server_url specified for client mode in {self}")
+
+ if pool_connections < 1 or pool_maxsize < 1:
+ raise ValueError(
+ f"pool_connections and pool_maxsize must be >= 1 for {self}",
+ )
+
+ if http_version >= 2:
+ if mode == "server" and (not tls_certfile or not tls_keyfile):
+ raise ValueError(
+ f"tls_certfile and tls_keyfile are required for "
+ f"HTTP/{http_version} server mode in {self}",
+ )
+ if mode == "client":
+ parsed = urlparse(server_url)
+ if parsed.scheme != "https":
+ raise ValueError(
+ f"HTTP/{http_version} client requires https:// server_url in {self}",
+ )
+
+ self.owner = owner
+ self.IN = True
+ self.mtu = mtu
+ self.check_user_agent = check_user_agent
+ self.user_agent = user_agent
+ self.serve_html_page = serve_html_page
+ self.html_file_path = html_file_path
+ self.html_content = None
+ self.pool_connections = pool_connections
+ self.pool_maxsize = pool_maxsize
+ self.keepalive_timeout = keepalive_timeout
+ self.tls_certfile = tls_certfile
+ self.tls_keyfile = tls_keyfile
+ self.tls_verify = tls_verify
+ self.tls_ca_certs = tls_ca_certs
+ self._tcp_accepts = 0
+ self._http_requests = 0
+ self._stats_lock = threading.Lock()
+ self._last_http_version = None
+ self._client_requests = 0
+ self.session = None
+ self._h3_session = None
+ self._asgi_shutdown = None
+ self._asgi_loop = None
+
+ if self.serve_html_page and self.html_file_path:
+ self._load_html_content()
+
+ self._recv_queue = Queue()
+ self._send_queue = Queue()
+ self._stop_event = threading.Event()
+ self._frame_remainder = b""
+ self._frame_lock = threading.Lock()
+
+ self.HW_MTU = mtu
+ self.online = False
+ self.bitrate = HTTPTunnelInterface.BITRATE_GUESS
+
+ if mode == "server":
+ self.listen_host = listen_host
+ self.listen_port = listen_port
+ else:
+ self.server_url = server_url
+ self.poll_interval = poll_interval
+
+ self.optimise_mtu()
+
+ if mode == "server":
+ if http_version == 1:
+ self.setup_server_http1()
+ else:
+ self.setup_server_asgi()
+ else:
+ self.setup_client()
+
+ def _load_html_content(self):
+ try:
+ if os.path.isfile(self.html_file_path):
+ with open(self.html_file_path, encoding="utf-8") as f:
+ self.html_content = f.read()
+ RNS.log(f"Loaded HTML content from {self.html_file_path}", RNS.LOG_INFO)
+ else:
+ RNS.log(f"HTML file not found: {self.html_file_path}", RNS.LOG_WARNING)
+ self.html_content = None
+ except Exception as e:
+ RNS.log(
+ f"Error loading HTML file {self.html_file_path}: {e}",
+ RNS.LOG_ERROR,
+ )
+ self.html_content = None
+
+ def _drain_send_frames(self):
+ parts = []
+ while not self._send_queue.empty():
+ try:
+ packet = self._send_queue.get_nowait()
+ except Empty:
+ break
+ if packet:
+ parts.append(HDLC.frame(packet))
+ return b"".join(parts)
+
+ def _ingest_wire_bytes(self, wire_data):
+ if not wire_data:
+ return
+
+ with self._frame_lock:
+ buffer = self._frame_remainder + wire_data
+ packets, self._frame_remainder = HDLC.deframe(buffer, self.HW_MTU)
+
+ for packet in packets:
+ if packet:
+ self._recv_queue.put(packet)
+
+ def _record_http_request(self):
+ with self._stats_lock:
+ self._http_requests += 1
+
+ def _handle_tunnel_exchange(self, method, path, headers, body):
+ """Shared request handler for HTTP/1.1 and ASGI paths.
+
+ Returns (status, content_type, response_body).
+ """
+ self._record_http_request()
+ headers_l = {str(k).lower(): str(v) for k, v in headers.items()}
+
+ if method == "GET" and path == "/":
+ if self.serve_html_page and self.html_content:
+ return (
+ 200,
+ "text/html; charset=utf-8",
+ self.html_content.encode("utf-8"),
+ )
+ return 404, "text/plain", b""
+
+ if method == "POST" and path == "/":
+ if self.check_user_agent:
+ user_agent = headers_l.get("user-agent", "")
+ if user_agent != self.user_agent:
+ RNS.log(
+ f"Rejected request with invalid User-Agent: {user_agent}",
+ RNS.LOG_WARNING,
+ )
+ return 403, "text/plain", b"Forbidden"
+
+ if body:
+ RNS.log(f"Received {len(body)} bytes from client", RNS.LOG_EXTREME)
+ self._ingest_wire_bytes(body)
+
+ server_data = self._drain_send_frames()
+ if server_data:
+ RNS.log(
+ f"Sending {len(server_data)} framed bytes to client",
+ RNS.LOG_EXTREME,
+ )
+ return 200, "application/octet-stream", server_data
+
+ return 404, "text/plain", b""
+
+ def connection_stats(self):
+ with self._stats_lock:
+ stats = {
+ "tcp_accepts": self._tcp_accepts,
+ "http_requests": self._http_requests,
+ "client_requests": self._client_requests,
+ "last_http_version": self._last_http_version,
+ "configured_http_version": self.http_version,
+ }
+ if self._h3_session is not None:
+ stats["pool_num_connections"] = 0 if self._h3_session._closed else 1
+ stats["pool_num_requests"] = self._h3_session._request_count
+ return stats
+
+ def _build_asgi_app(self):
+ interface = self
+
+ async def app(scope, receive, send):
+ if scope["type"] != "http":
+ return
+
+ headers = {
+ key.decode("latin1"): value.decode("latin1")
+ for key, value in scope.get("headers", [])
+ }
+ body = b""
+ while True:
+ message = await receive()
+ body += message.get("body", b"")
+ if not message.get("more_body", False):
+ break
+
+ status, content_type, response_body = interface._handle_tunnel_exchange(
+ scope.get("method", "GET"),
+ scope.get("path", "/"),
+ headers,
+ body,
+ )
+ await send(
+ {
+ "type": "http.response.start",
+ "status": status,
+ "headers": [
+ (b"content-type", content_type.encode()),
+ (b"content-length", str(len(response_body)).encode()),
+ ],
+ }
+ )
+ await send({"type": "http.response.body", "body": response_body})
+
+ return app
+
+ def setup_server_http1(self):
+ interface_instance = self
+
+ class TunnelRequestHandler(BaseHTTPRequestHandler):
+ protocol_version = "HTTP/1.1"
+
+ def _send_common_headers(self, content_type, content_length):
+ self.send_header("Content-Type", content_type)
+ self.send_header("Content-Length", str(content_length))
+ self.send_header("Connection", "keep-alive")
+ self.send_header(
+ "Keep-Alive",
+ f"timeout={interface_instance.keepalive_timeout}, max=1000",
+ )
+
+ def do_GET(self):
+ status, content_type, body = interface_instance._handle_tunnel_exchange(
+ "GET",
+ self.path,
+ self.headers,
+ b"",
+ )
+ self.send_response(status)
+ self._send_common_headers(content_type, len(body))
+ self.end_headers()
+ if body:
+ self.wfile.write(body)
+
+ def do_POST(self):
+ content_length = int(self.headers.get("Content-Length", 0))
+ body = self.rfile.read(content_length) if content_length > 0 else b""
+ status, content_type, response = interface_instance._handle_tunnel_exchange(
+ "POST",
+ self.path,
+ self.headers,
+ body,
+ )
+ self.send_response(status)
+ self._send_common_headers(content_type, len(response))
+ self.end_headers()
+ if response:
+ self.wfile.write(response)
+
+ def log_message(self, fmt, *args):
+ pass
+
+ class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
+ daemon_threads = True
+ allow_reuse_address = True
+
+ def get_request(self):
+ request, client_address = super().get_request()
+ with interface_instance._stats_lock:
+ interface_instance._tcp_accepts += 1
+ try:
+ request.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
+ except OSError:
+ pass
+ return request, client_address
+
+ def run_server():
+ try:
+ self._http_server = ThreadedHTTPServer(
+ (self.listen_host, self.listen_port),
+ TunnelRequestHandler,
+ )
+ self._http_server.daemon_threads = True
+ self._http_server.serve_forever()
+ except Exception as e:
+ if not self._stop_event.is_set():
+ RNS.log(f"HTTP server error for {self}: {e}", RNS.LOG_ERROR)
+ if RNS.Reticulum.panic_on_interface_error:
+ RNS.panic()
+
+ self._server_thread = threading.Thread(target=run_server, daemon=True)
+ self._server_thread.start()
+ self._start_receive_loop()
+ self.online = True
+ RNS.log(
+ f"HTTP/1.1 server started on http://{self.listen_host}:{self.listen_port}",
+ RNS.LOG_NOTICE,
+ )
+
+ def setup_server_asgi(self):
+ from hypercorn.asyncio import serve
+ from hypercorn.config import Config
+
+ config = Config()
+ bind = f"{self.listen_host}:{self.listen_port}"
+ config.bind = [bind]
+ config.certfile = self.tls_certfile
+ config.keyfile = self.tls_keyfile
+ config.alpn_protocols = ["h2", "http/1.1"]
+ if self.http_version == 3:
+ config.quic_bind = [bind]
+ config.alpn_protocols = ["h3", "h2", "http/1.1"]
+
+ app = self._build_asgi_app()
+ self._asgi_shutdown = asyncio.Event()
+
+ def run_server():
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ self._asgi_loop = loop
+ try:
+ loop.run_until_complete(
+ serve(app, config, shutdown_trigger=self._asgi_shutdown.wait),
+ )
+ except Exception as e:
+ if not self._stop_event.is_set():
+ RNS.log(f"ASGI HTTP server error for {self}: {e}", RNS.LOG_ERROR)
+ if RNS.Reticulum.panic_on_interface_error:
+ RNS.panic()
+ finally:
+ loop.close()
+
+ self._server_thread = threading.Thread(target=run_server, daemon=True)
+ self._server_thread.start()
+ self._start_receive_loop()
+ self.online = True
+ proto = f"HTTP/{self.http_version}"
+ RNS.log(
+ f"{proto} server started on https://{self.listen_host}:{self.listen_port}",
+ RNS.LOG_NOTICE,
+ )
+
+ def _start_receive_loop(self):
+ thread = threading.Thread(target=self.receive_loop, daemon=True)
+ thread.start()
+
+ def setup_client(self):
+ self._consecutive_failures = 0
+ self._max_backoff = 30.0
+
+ if self.http_version == 3:
+ self._h3_session = _H3Session(
+ self.server_url,
+ self.user_agent,
+ verify=self.tls_verify,
+ ca_certs=self.tls_ca_certs,
+ )
+ self._last_http_version = "HTTP/3"
+ else:
+ verify = self.tls_ca_certs if self.tls_ca_certs else self.tls_verify
+ limits = httpx.Limits(
+ max_connections=max(self.pool_maxsize, 1),
+ max_keepalive_connections=max(self.pool_maxsize, 1),
+ keepalive_expiry=float(self.keepalive_timeout),
+ )
+ self.session = httpx.Client(
+ http2=(self.http_version == 2),
+ verify=verify,
+ headers={
+ "User-Agent": self.user_agent,
+ "Accept-Encoding": "identity",
+ },
+ limits=limits,
+ timeout=httpx.Timeout(5.0),
+ )
+
+ thread = threading.Thread(target=self.client_loop, daemon=True)
+ thread.start()
+ self.online = True
+ RNS.log(
+ f"HTTP/{self.http_version} client started, connecting to {self.server_url}",
+ RNS.LOG_NOTICE,
+ )
+
+ def receive_loop(self):
+ while not self._stop_event.is_set():
+ try:
+ received_data = self._recv_queue.get(timeout=1)
+ if received_data:
+ self.process_incoming(received_data)
+ except Empty:
+ continue
+ except Exception as e:
+ if not self._stop_event.is_set():
+ RNS.log(f"Error in receive loop for {self}: {e}", RNS.LOG_ERROR)
+
+ def _client_exchange(self, data_to_send):
+ if self.http_version == 3:
+ body = self._h3_session.post(data_to_send, timeout=5.0)
+ self._client_requests += 1
+ self._last_http_version = "HTTP/3"
+ return body
+
+ response = self.session.post(self.server_url, content=data_to_send)
+ response.raise_for_status()
+ self._client_requests += 1
+ self._last_http_version = response.http_version
+ return response.content
+
+ def client_loop(self):
+ while not self._stop_event.is_set():
+ data_to_send = self._drain_send_frames()
+
+ try:
+ RNS.log(
+ f"Sending {len(data_to_send)} framed bytes to server",
+ RNS.LOG_EXTREME,
+ )
+ content = self._client_exchange(data_to_send)
+
+ if content:
+ RNS.log(
+ f"Received {len(content)} bytes from server",
+ RNS.LOG_EXTREME,
+ )
+ self._ingest_wire_bytes(content)
+
+ while not self._recv_queue.empty():
+ try:
+ packet = self._recv_queue.get_nowait()
+ except Empty:
+ break
+ if packet:
+ self.process_incoming(packet)
+
+ if self._consecutive_failures > 0:
+ RNS.log(f"Reconnected to server for {self}", RNS.LOG_INFO)
+ self._consecutive_failures = 0
+
+ except Exception as e:
+ if self._stop_event.is_set():
+ break
+ self._consecutive_failures += 1
+ if self._consecutive_failures % 10 == 1:
+ RNS.log(
+ f"Error communicating with server for {self} "
+ f"(attempt {self._consecutive_failures}): {e}",
+ RNS.LOG_WARNING,
+ )
+
+ if self._stop_event.is_set():
+ break
+
+ if self._consecutive_failures > 0:
+ delay = min(
+ self.poll_interval * (2 ** min(self._consecutive_failures - 1, 5)),
+ self._max_backoff,
+ )
+ else:
+ delay = self.poll_interval
+
+ self._stop_event.wait(delay)
+
+ def process_incoming(self, data):
+ if len(data) > 0 and self.online:
+ self.rxb += len(data)
+ self.owner.inbound(data, self)
+
+ def process_outgoing(self, data):
+ if self.online:
+ if len(data) > self.mtu:
+ RNS.log(
+ f"Payload too large ({len(data)} > {self.mtu}) for {self}",
+ RNS.LOG_ERROR,
+ )
+ return
+
+ self._send_queue.put(data)
+ self.txb += len(data)
+
+ def detach(self):
+ RNS.log(f"Detaching {self}", RNS.LOG_DEBUG)
+ self._stop_event.set()
+ self.online = False
+
+ if self.mode == "client":
+ if self.session is not None:
+ try:
+ self.session.close()
+ except Exception as e:
+ RNS.log(
+ f"Error closing HTTP session for {self}: {e}",
+ RNS.LOG_DEBUG,
+ )
+ if self._h3_session is not None:
+ try:
+ self._h3_session.close()
+ except Exception as e:
+ RNS.log(
+ f"Error closing HTTP/3 session for {self}: {e}",
+ RNS.LOG_DEBUG,
+ )
+
+ if self.mode == "server":
+ if self.http_version == 1:
+ httpd = getattr(self, "_http_server", None)
+ if httpd is not None:
+ def _shutdown():
+ try:
+ httpd.shutdown()
+ except Exception:
+ pass
+ try:
+ httpd.server_close()
+ except Exception:
+ pass
+
+ threading.Thread(target=_shutdown, daemon=True).start()
+
+ if hasattr(self, "_server_thread") and self._server_thread:
+ self._server_thread.join(timeout=2)
+ if self._server_thread.is_alive() and httpd is not None:
+ try:
+ httpd.socket.close()
+ except Exception:
+ pass
+ self._server_thread.join(timeout=1)
+ else:
+ if self._asgi_loop is not None and self._asgi_shutdown is not None:
+ self._asgi_loop.call_soon_threadsafe(self._asgi_shutdown.set)
+ if hasattr(self, "_server_thread") and self._server_thread:
+ self._server_thread.join(timeout=5)
+
+ def should_ingress_limit(self):
+ return False
+
+ def __str__(self):
+ name = getattr(self, "name", "?")
+ ver = getattr(self, "http_version", "?")
+ if self.mode == "server":
+ lh = getattr(self, "listen_host", "?")
+ lp = getattr(self, "listen_port", "?")
+ return f"HTTPTunnelInterface[{name}/HTTP{ver}/server/{lh}:{lp}]"
+ if self.mode == "client" and getattr(self, "server_url", None) is not None:
+ return f"HTTPTunnelInterface[{name}/HTTP{ver}/client/{self.server_url}]"
+ return f"HTTPTunnelInterface[{name}/HTTP{ver}/{self.mode}]"
+
+
+interface_class = HTTPTunnelInterface

diff --git a/vendor/rns_over_http/LICENSE b/vendor/rns_over_http/LICENSE
new file mode 100644
index 00000000..24a9a940
--- /dev/null
+++ b/vendor/rns_over_http/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Ivan / Quad4.io
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

diff --git a/vendor/rns_over_http/README-RU.md b/vendor/rns_over_http/README-RU.md
new file mode 100644
index 00000000..08c804c8
--- /dev/null
+++ b/vendor/rns_over_http/README-RU.md
@@ -0,0 +1,138 @@
+# **RNS-over-HTTP**
+
+Интерфейс Reticulum, который туннелирует трафик через стандартные HTTP/S POST-запросы. Это позволяет Reticulum работать в сетях, где разрешён только веб-трафик, эффективно обходя файрволы, DPI и другие ограничения.
+
+Границы пакетов на HTTP-теле используют то же упрощённое HDLC-фреймирование, что и `PipeInterface` Reticulum, поэтому несколько пакетов могут передаваться в одном теле запроса или ответа без слияния.
+
+[Не-GitHub-зеркало](https://lavaforge.org/Ivan/RNS-over-HTTP). Кроме того, файлы проекта доступны в сети Reticulum по адресу `RNS-over-HTTP`.
+
+## **Обзор**
+
+RNS-over-HTTP создаёт двунаправленный транспортный уровень, используя простую клиент-серверную модель:
+
+* **Сервер**: Запускается на машине с публичным IP-адресом и прослушивает HTTP-запросы.
+* **Клиент**: Может находиться за файрволом или NAT, ему требуется только исходящий доступ в интернет.
+
+Клиент опрашивает сервер с помощью HTTP POST-запросов, отправляя исходящие данные в теле запроса и получая входящие данные в теле ответа. Это делает трафик похожим на обычную веб-активность.
+
+## **Как это работает**
+
+Интерфейс имитирует постоянное соединение, используя механизм, подобный long-polling:
+
+1. Клиент отправляет HTTP POST на сервер с ожидающими HDLC-фреймами в теле запроса.
+2. Сервер декодирует входящие пакеты для Reticulum и сразу возвращает исходящую очередь (тоже в HDLC) в теле ответа.
+3. Клиент принимает ответ, декодирует фреймы и передаёт пакеты в Reticulum.
+4. После настраиваемого интервала опроса клиент повторяет цикл.
+
+## **Особенности**
+
+* **Обход файрволов и DPI**: Туннелирует трафик через стандартные порты HTTP/S (80/443).
+* **Двунаправленная связь**: Полнодуплексная передача данных.
+* **Совместимое с Pipe фреймирование**: HDLC FLAG/ESC как у `PipeInterface`.
+* **Простая настройка**: Python, `httpx`, Hypercorn/aioquic для HTTP/2–3 и Reticulum (`rns`).
+* **Надёжность**: Повторное подключение с экспоненциальной задержкой.
+* **Гибкость**: Настраиваемые MTU и интервал опроса.
+* **Совместимость с прокси**: Caddy, Nginx и аналоги.
+* **Повторное использование соединений**: keep-alive / мультиплексирование.
+* **HTTP/1.1, HTTP/2 и HTTP/3**: `http_version = 1|2|3` (по умолчанию `1`).
+
+## **Начало работы**
+
+### **Требования**
+
+* Python 3.10 или новее
+* [Poetry](https://python-poetry.org/docs/#installation)
+
+### **Установка**
+
+1. **Установите зависимости через Poetry** (из корня репозитория):
+
+ ```bash
+ poetry install
+ ```
+
+2. **Установите пользовательский интерфейс:** скопируйте `HTTPInterface.py` в каталог интерфейсов Reticulum, например `~/.reticulum/interfaces/`.
+
+### **Тесты**
+
+```bash
+poetry run pytest -m "not live"
+poetry run pytest -m live
+```
+
+## **Конфигурация**
+
+Добавьте блок интерфейса в `~/.reticulum/config` на сервере и на клиенте. Поле `type` должно совпадать с именем модуля (`HTTPInterface`).
+
+### **Конфигурация сервера**
+
+```ini
+[[HTTP Server Interface]]
+ type = HTTPInterface
+ enabled = true
+ mode = server
+ listen_host = 0.0.0.0
+ listen_port = 8080
+ mtu = 4096
+ check_user_agent = true
+ user_agent = RNS-HTTP-Tunnel/1.0
+```
+
+### **Конфигурация клиента**
+
+```ini
+[[HTTP Client Interface]]
+ type = HTTPInterface
+ enabled = true
+ mode = client
+ server_url = http://your-server-ip-or-domain:8080/
+ poll_interval = 0.1
+ mtu = 4096
+ user_agent = RNS-HTTP-Tunnel/1.0
+```
+
+## **Параметры конфигурации**
+
+### Общие
+
+* `mtu`: максимальный размер пакета в байтах (по умолчанию `4096`)
+* `user_agent`: строка User-Agent (по умолчанию `RNS-HTTP-Tunnel/1.0`)
+
+### Режим сервера
+
+* `mode = server`
+* `listen_host` (по умолчанию `0.0.0.0`)
+* `listen_port` (по умолчанию `8080`)
+* `check_user_agent` (по умолчанию `true`)
+* `serve_html_page` / `html_file_path`: необязательная HTML-страница на GET `/`
+
+### Режим клиента
+
+* `mode = client`
+* `server_url` (обязателен)
+* `poll_interval` (по умолчанию `0.1`)
+* `http_version = 1|2|3` (по умолчанию `1`)
+* для `2` и `3`: `https://`, на сервере `tls_certfile` / `tls_keyfile`
+* `tls_verify` / `tls_ca_certs` на клиенте
+* `pool_connections` / `pool_maxsize` / `keepalive_timeout`
+
+## **Обратный прокси (пример Caddy)**
+
+```caddy
+example.yourdomain.com {
+ reverse_proxy 127.0.0.1:8080
+ header {
+ -Server
+ X-Content-Type-Options nosniff
+ }
+}
+```
+
+## **Безопасность**
+
+* Используйте HTTPS там, где возможно.
+* По умолчанию сервер проверяет User-Agent. Задайте одинаковый `user_agent` на обеих сторонах или отключите проверку через `check_user_agent = false`.
+
+## **Лицензия**
+
+Проект распространяется под [лицензией MIT](LICENSE).

diff --git a/vendor/rns_over_http/README.md b/vendor/rns_over_http/README.md
new file mode 100644
index 00000000..6b9d678a
--- /dev/null
+++ b/vendor/rns_over_http/README.md
@@ -0,0 +1,201 @@
+# RNS-over-HTTP
+
+[Русский](README-RU.md)
+
+A custom Reticulum interface that tunnels traffic over standard HTTP/S POST requests. This allows Reticulum to operate on networks where only web traffic is permitted, effectively bypassing firewalls, DPI, and other restrictions.
+
+Packet boundaries on the wire use the same simplified HDLC framing as Reticulum's `PipeInterface`, so multiple packets can share one HTTP body without merging.
+
+[Non-GitHub Mirror](https://lavaforge.org/Ivan/RNS-over-HTTP). Also available on the network `RNS-over-HTTP` node.
+
+## Overview
+
+RNS-over-HTTP creates a bidirectional transport layer using a simple client-server model:
+
+- **Server**: Runs on a machine with a public IP, listening for HTTP requests.
+- **Client**: Can be behind a firewall or NAT, only needing outbound internet access.
+
+The client polls the server with HTTP POST requests, sending any outbound data in the request body and receiving inbound data in the response body. This makes the traffic appear as normal web activity.
+
+## How It Works
+
+The interface mimics a persistent connection using a long-polling-like mechanism:
+
+1. The client sends an HTTP POST request to the server, with any pending HDLC-framed packets in the request body.
+2. The server receives the request. It deframes inbound packets for Reticulum and immediately sends back any queued outbound packets (also HDLC-framed) in the HTTP response body.
+3. The client receives the response, deframes packets, and hands them to Reticulum.
+4. After a short, configurable polling interval, the client repeats the process.
+
+This continuous cycle creates a reliable, albeit higher-latency, communication channel.
+
+## Features
+
+- **Firewall & DPI Evasion**: Tunnels any traffic through standard HTTP/S ports (80/443).
+- **Bidirectional Communication**: Full-duplex data transfer.
+- **Pipe-compatible framing**: HDLC FLAG/ESC framing identical to `PipeInterface`.
+- **Simple setup**: Python, `httpx`, Hypercorn/aioquic for HTTP/2–3, and Reticulum (`rns`). Use Poetry in this repo for deps and tests.
+- **Reliable**: Automatic connection retry with exponential backoff.
+- **Flexible**: Supports custom MTU sizes and configurable polling intervals.
+- **Proxy-Friendly**: Works seamlessly behind reverse proxies like Caddy or Nginx.
+- **Connection reuse**: Keep-alive / multiplexed sessions by default, reducing handshake noise visible to DPI.
+- **HTTP/1.1, HTTP/2, and HTTP/3**: Choose the version that matches your path. Default stays HTTP/1.1 for cleartext and reverse-proxy backends.
+
+## Getting Started
+
+### Requirements
+
+- Python 3.10 or later
+- [Poetry](https://python-poetry.org/docs/#installation) (for a reproducible dev environment and tests)
+
+### Installation
+
+1. **Install dependencies with Poetry** (from this repository root):
+ ```bash
+ poetry install
+ ```
+
+2. **Install the custom interface for Reticulum:**
+ Copy `HTTPInterface.py` into your Reticulum interfaces directory: `~/.reticulum/interfaces/`.
+
+### Tests
+
+```bash
+# Unit, integration, and fuzz tests
+poetry run pytest -m "not live"
+
+# Full live Reticulum tests (two instances over HTTP + local shared-instance client)
+poetry run pytest -m live
+```
+
+## Configuration
+
+Add an interface entry to your Reticulum configuration file (`~/.reticulum/config`) on both the server and client machines. The config `type` must match the module basename (`HTTPInterface`).
+
+### Server Configuration
+
+The server listens for incoming connections from clients.
+
+```ini
+[[HTTP Server Interface]]
+ type = HTTPInterface
+ enabled = true
+ mode = server
+ listen_host = 0.0.0.0
+ listen_port = 8080
+ mtu = 4096
+ check_user_agent = true
+ user_agent = RNS-HTTP-Tunnel/1.0
+```
+
+### Client Configuration
+
+The client connects to the server's public URL.
+
+```ini
+[[HTTP Client Interface]]
+ type = HTTPInterface
+ enabled = true
+ mode = client
+ server_url = http://your-server-ip-or-domain:8080/
+ poll_interval = 0.1
+ mtu = 4096
+ user_agent = RNS-HTTP-Tunnel/1.0
+```
+
+## Configuration Options
+
+### Common Options
+
+- `mtu`: Maximum Transmission Unit in bytes (default: `4096`).
+- `name`: Interface name for logging and identification.
+- `user_agent`: User-Agent string for HTTP requests and server checks (default: `RNS-HTTP-Tunnel/1.0`).
+
+### Server Mode Options
+
+- `mode`: Must be set to `server`.
+- `listen_host`: Host to bind the HTTP server to (default: `0.0.0.0`).
+- `listen_port`: Port to listen on (default: `8080`).
+- `check_user_agent`: Whether to validate User-Agent headers (default: `true`).
+- `serve_html_page`: Serve an HTML page on GET `/` (default: `false`).
+- `html_file_path`: Path to the HTML file used when `serve_html_page` is enabled.
+
+### Client Mode Options
+
+- `mode`: Must be set to `client`.
+- `server_url`: Full URL of the server to connect to (required for client mode).
+- `poll_interval`: Polling interval in seconds (default: `0.1`).
+- `pool_connections` / `pool_maxsize`: Keepalive pool sizing for HTTP/1.1 and HTTP/2 (default: `1`).
+
+### HTTP Version and TLS
+
+- `http_version`: `1` (default), `2`, or `3`.
+- **HTTP/1.1**: cleartext `http://` or TLS. Best behind Caddy/nginx that already terminate HTTP/2 or HTTP/3.
+- **HTTP/2**: requires `https://` and server `tls_certfile` / `tls_keyfile`. Client uses `httpx` with ALPN `h2`.
+- **HTTP/3**: requires `https://` and the same TLS files. Server enables Hypercorn QUIC bind. Client keeps one aioquic QUIC session across polls.
+- `tls_verify`: client certificate verification (default: `true`). Set `false` only for lab/self-signed setups.
+- `tls_ca_certs`: optional CA bundle path for client verification.
+- `keepalive_timeout`: idle keepalive budget in seconds (default: `60`).
+
+Example HTTP/2 server/client:
+
+```ini
+[[HTTP Server Interface]]
+ type = HTTPInterface
+ enabled = true
+ mode = server
+ http_version = 2
+ listen_host = 0.0.0.0
+ listen_port = 443
+ tls_certfile = /path/to/fullchain.pem
+ tls_keyfile = /path/to/privkey.pem
+
+[[HTTP Client Interface]]
+ type = HTTPInterface
+ enabled = true
+ mode = client
+ http_version = 2
+ server_url = https://your-server.example/
+ tls_verify = true
+```
+
+## Reverse Proxy Setup (Caddy Example)
+
+### Subdomain
+
+```caddy
+# Caddyfile for example.yourdomain.com
+example.yourdomain.com {
+ reverse_proxy 127.0.0.1:8080
+
+ header {
+ # Hide the server software version
+ -Server
+ # Prevent MIME-type sniffing
+ X-Content-Type-Options nosniff
+ }
+}
+```
+
+### Main Domain
+
+```caddy
+yourdomain.com {
+ reverse_proxy 127.0.0.1:8080
+
+ header {
+ # Hide the server software version
+ -Server
+ # Prevent MIME-type sniffing
+ X-Content-Type-Options nosniff
+ }
+}
+```
+
+## Security Considerations
+
+- **Use HTTPS**: Helps bypass some firewalls and DPI that could potentially see reticulum data.
+- **User-Agent Check**: By default, the server validates the `User-Agent` header (`RNS-HTTP-Tunnel/1.0`). This provides basic protection against web crawlers and casual scanning. To mimic a common browser under sophisticated DPI, set a matching `user_agent` on both sides and keep `check_user_agent = true`, or set `check_user_agent = false` on the server.
+
+## License
+
+This project is licensed under the [MIT License](LICENSE).

diff --git a/vendor/rns_over_http/index.html b/vendor/rns_over_http/index.html
new file mode 100644
index 00000000..ac0406bc
--- /dev/null
+++ b/vendor/rns_over_http/index.html
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Service Status</title>
+ <style>
+ body { font-family: Arial, sans-serif; margin: 40px; background-color: #f5f5f5; }
+ .container { max-width: 800px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
+ .status { color: #28a745; font-weight: bold; }
+ .header { text-align: center; margin-bottom: 30px; }
+ </style>
+</head>
+<body>
+ <div class="container">
+ <div class="header">
+ <h1>Service Status</h1>
+ <p class="status">✓ All systems operational</p>
+ </div>
+ <p>This service is currently running normally. If you're seeing this page, the service is functioning as expected.</p>
+ <p>For technical inquiries, please contact the system administrator.</p>
+ </div>
+</body>
+</html>

diff --git a/vendor/rns_over_http/poetry.lock b/vendor/rns_over_http/poetry.lock
new file mode 100644
index 00000000..4a3b9d15
--- /dev/null
+++ b/vendor/rns_over_http/poetry.lock
@@ -0,0 +1,851 @@
+# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
+
+[[package]]
+name = "aioquic"
+version = "1.3.0"
+description = "An implementation of QUIC and HTTP/3"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "aioquic-1.3.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:59da070ff0f55a54f5623c9190dbc86638daa0bcf84bbdb11ebe507abc641435"},
+ {file = "aioquic-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:48590fa38ec13f01a3d4e44fb3cfd373661094c9c7248f3c54d2d9512b6c3469"},
+ {file = "aioquic-1.3.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:019b16580d53541b5d77b4a44a61966921156554fad2536d74895713c800caa5"},
+ {file = "aioquic-1.3.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:396e5f53f6ddb27713d9b5bb11d8f0f842e42857b7e671c5ae203bf618528550"},
+ {file = "aioquic-1.3.0-cp310-abi3-manylinux_2_28_i686.whl", hash = "sha256:4098afc6337adf19bdb54474f6c37983988e7bfa407892a277259c32eb664b00"},
+ {file = "aioquic-1.3.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:48292279a248422b6289fffd82159eba8d8b35ff4b1f660b9f74ff85e10ca265"},
+ {file = "aioquic-1.3.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:0538acdfbf839d87b175676664737c248cd51f1a2295c5fef8e131ddde478a86"},
+ {file = "aioquic-1.3.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a8881239801279188e33ced6f9849cedf033325a48a6f44d7e55e583abc555a3"},
+ {file = "aioquic-1.3.0-cp310-abi3-win32.whl", hash = "sha256:ba30016244e45d9222fdd1fbd4e8b0e5f6811e81a5d0643475ad7024a537274a"},
+ {file = "aioquic-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:2d7957ba14a6c5efcc14fdc685ccda7ecf0ad048c410a2bdcad1b63bf9527e8e"},
+ {file = "aioquic-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:9d15a89213d38cbc4679990fa5151af8ea02655a1d6ce5ec972b0a6af74d5f1c"},
+ {file = "aioquic-1.3.0.tar.gz", hash = "sha256:28d070b2183e3e79afa9d4e7bd558960d0d53aeb98bc0cf0a358b279ba797c92"},
+]
+
+[package.dependencies]
+certifi = "*"
+cryptography = ">=42.0.0"
+pylsqpack = ">=0.3.3,<0.4.0"
+pyopenssl = ">=24"
+service-identity = ">=24.1.0"
+
+[package.extras]
+dev = ["coverage[toml] (>=7.2.2)"]
+
+[[package]]
+name = "anyio"
+version = "4.14.2"
+description = "High-level concurrency and networking framework on top of asyncio or Trio"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494"},
+ {file = "anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f"},
+]
+
+[package.dependencies]
+exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""}
+idna = ">=2.8"
+typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
+
+[package.extras]
+trio = ["trio (>=0.32.0)"]
+
+[[package]]
+name = "attrs"
+version = "26.1.0"
+description = "Classes Without Boilerplate"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"},
+ {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"},
+]
+
+[[package]]
+name = "certifi"
+version = "2026.6.17"
+description = "Python package for providing Mozilla's CA Bundle."
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"},
+ {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"},
+]
+
+[[package]]
+name = "cffi"
+version = "2.1.0"
+description = "Foreign Function Interface for Python calling C code."
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+markers = "platform_python_implementation != \"PyPy\""
+files = [
+ {file = "cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0"},
+ {file = "cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd"},
+ {file = "cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46"},
+ {file = "cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2"},
+ {file = "cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd"},
+ {file = "cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3"},
+ {file = "cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0"},
+ {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43"},
+ {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c"},
+ {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd"},
+ {file = "cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f"},
+ {file = "cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da"},
+ {file = "cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc"},
+ {file = "cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7"},
+ {file = "cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93"},
+ {file = "cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2"},
+ {file = "cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c"},
+ {file = "cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f"},
+ {file = "cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565"},
+ {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c"},
+ {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02"},
+ {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e"},
+ {file = "cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479"},
+ {file = "cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458"},
+ {file = "cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d"},
+ {file = "cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f"},
+ {file = "cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde"},
+ {file = "cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d"},
+ {file = "cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7"},
+ {file = "cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b"},
+ {file = "cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7"},
+ {file = "cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66"},
+ {file = "cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe"},
+ {file = "cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b"},
+ {file = "cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a"},
+ {file = "cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384"},
+ {file = "cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6"},
+ {file = "cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda"},
+ {file = "cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b"},
+ {file = "cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a"},
+ {file = "cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea"},
+ {file = "cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db"},
+ {file = "cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f"},
+ {file = "cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d"},
+ {file = "cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0"},
+ {file = "cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224"},
+ {file = "cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c"},
+ {file = "cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a"},
+ {file = "cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2"},
+ {file = "cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512"},
+ {file = "cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f"},
+ {file = "cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a"},
+ {file = "cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3"},
+ {file = "cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d"},
+ {file = "cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac"},
+ {file = "cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6"},
+ {file = "cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913"},
+ {file = "cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d"},
+ {file = "cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5"},
+ {file = "cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce"},
+ {file = "cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326"},
+ {file = "cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd"},
+ {file = "cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb"},
+ {file = "cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804"},
+ {file = "cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714"},
+ {file = "cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376"},
+ {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98"},
+ {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13"},
+ {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d"},
+ {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056"},
+ {file = "cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4"},
+ {file = "cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94"},
+ {file = "cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76"},
+ {file = "cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5"},
+ {file = "cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8"},
+ {file = "cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c"},
+ {file = "cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001"},
+ {file = "cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3"},
+ {file = "cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc"},
+ {file = "cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699"},
+ {file = "cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022"},
+ {file = "cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0"},
+ {file = "cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1"},
+ {file = "cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28"},
+ {file = "cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629"},
+ {file = "cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6"},
+ {file = "cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853"},
+ {file = "cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda"},
+ {file = "cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc"},
+ {file = "cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca"},
+ {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d"},
+ {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8"},
+ {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd"},
+ {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f"},
+ {file = "cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc"},
+ {file = "cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9"},
+ {file = "cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b"},
+ {file = "cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5"},
+ {file = "cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210"},
+ {file = "cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9"},
+]
+
+[package.dependencies]
+pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+groups = ["dev"]
+markers = "sys_platform == \"win32\""
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "cryptography"
+version = "49.0.0"
+description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
+optional = false
+python-versions = "!=3.9.0,!=3.9.1,>=3.9"
+groups = ["main"]
+files = [
+ {file = "cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db"},
+ {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db"},
+ {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325"},
+ {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2"},
+ {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b"},
+ {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6"},
+ {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c"},
+ {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7"},
+ {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68"},
+ {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9"},
+ {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f"},
+ {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459"},
+ {file = "cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e"},
+ {file = "cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7"},
+ {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d"},
+ {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa"},
+ {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb"},
+ {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d"},
+ {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561"},
+ {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122"},
+ {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505"},
+ {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866"},
+ {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8"},
+ {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3"},
+ {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27"},
+ {file = "cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61"},
+ {file = "cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a"},
+ {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4"},
+ {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18"},
+ {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69"},
+ {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64"},
+ {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21"},
+ {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9"},
+ {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc"},
+ {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8"},
+ {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36"},
+ {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e"},
+ {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b"},
+ {file = "cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001"},
+ {file = "cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b"},
+ {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838"},
+ {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5"},
+ {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615"},
+ {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6"},
+ {file = "cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6"},
+ {file = "cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"},
+]
+
+[package.dependencies]
+cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""}
+typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11.0\""}
+
+[package.extras]
+ssh = ["bcrypt (>=3.1.5)"]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.3.1"
+description = "Backport of PEP 654 (exception groups)"
+optional = false
+python-versions = ">=3.7"
+groups = ["main", "dev"]
+markers = "python_version == \"3.10\""
+files = [
+ {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"},
+ {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"},
+]
+
+[package.dependencies]
+typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
+
+[package.extras]
+test = ["pytest (>=6)"]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
+ {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
+]
+
+[[package]]
+name = "h2"
+version = "4.3.0"
+description = "Pure-Python HTTP/2 protocol implementation"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd"},
+ {file = "h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1"},
+]
+
+[package.dependencies]
+hpack = ">=4.1,<5"
+hyperframe = ">=6.1,<7"
+
+[[package]]
+name = "hpack"
+version = "4.2.0"
+description = "Pure-Python HPACK header encoding"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986"},
+ {file = "hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0"},
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+description = "A minimal low-level HTTP client."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
+ {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
+]
+
+[package.dependencies]
+certifi = "*"
+h11 = ">=0.16"
+
+[package.extras]
+asyncio = ["anyio (>=4.0,<5.0)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (==1.*)"]
+trio = ["trio (>=0.22.0,<1.0)"]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+description = "The next generation HTTP client."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
+ {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
+]
+
+[package.dependencies]
+anyio = "*"
+certifi = "*"
+h2 = {version = ">=3,<5", optional = true, markers = "extra == \"http2\""}
+httpcore = "==1.*"
+idna = "*"
+
+[package.extras]
+brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
+cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
+http2 = ["h2 (>=3,<5)"]
+socks = ["socksio (==1.*)"]
+zstd = ["zstandard (>=0.18.0)"]
+
+[[package]]
+name = "hypercorn"
+version = "0.18.0"
+description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd"},
+ {file = "hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da"},
+]
+
+[package.dependencies]
+aioquic = {version = ">=0.9.0", optional = true, markers = "extra == \"h3\""}
+exceptiongroup = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
+h11 = "*"
+h2 = ">=4.3.0"
+priority = "*"
+taskgroup = {version = "*", markers = "python_version < \"3.11\""}
+tomli = {version = "*", markers = "python_version < \"3.11\""}
+typing_extensions = {version = "*", markers = "python_version < \"3.11\""}
+wsproto = ">=0.14.0"
+
+[package.extras]
+docs = ["pydata_sphinx_theme", "sphinxcontrib_mermaid"]
+h3 = ["aioquic (>=0.9.0)"]
+trio = ["trio"]
+uvloop = ["uvloop"]
+
+[[package]]
+name = "hyperframe"
+version = "6.1.0"
+description = "Pure-Python HTTP/2 framing"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"},
+ {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"},
+]
+
+[[package]]
+name = "hypothesis"
+version = "6.157.0"
+description = "The property-based testing library for Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "hypothesis-6.157.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c958d78cfe93e00fc410d8dadfafac379bff3633348e87a4c5a5af47a0ef0873"},
+ {file = "hypothesis-6.157.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:6ffb3948acffacd2fe9cd56b5b259f48002ed97d8addc2087db55d2e76ab8183"},
+ {file = "hypothesis-6.157.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764ea1cb63e8d75b0b3889da30640466425b5a2fcaec803952b3fb13e56515a"},
+ {file = "hypothesis-6.157.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:116734981c3c59e06e6ed1446b054fa92db999cbdbc354c3335c593ce917ebb9"},
+ {file = "hypothesis-6.157.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b93a357d1ff0dae972e8accbfe15cd91c1eeb8fa766a2a1c361b56bd0414f80"},
+ {file = "hypothesis-6.157.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e8778a10749cfd3cd236224dc78b354b14970a07cd5630345269ffbf8830db9a"},
+ {file = "hypothesis-6.157.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b718c800bfd1fbec5a11ac1e56c28a5115a31af499ebb6acfc6e13922a6dd0d4"},
+ {file = "hypothesis-6.157.0-cp310-abi3-win32.whl", hash = "sha256:17e9264974ffdeaa95e48caa36db418d49bb1f0fa164be616adea64f5547d900"},
+ {file = "hypothesis-6.157.0-cp310-abi3-win_amd64.whl", hash = "sha256:c8ac434ead091077b1fc9ca69ed7162cd644e42bf75e59a11ae35258e8922225"},
+ {file = "hypothesis-6.157.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:5dd5b530a847cfecd7dc7ec97d0776e2744e0abab16bb8ed94a100a463209003"},
+ {file = "hypothesis-6.157.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37953dfffdf4c5147d92ce2269aed9f66123edc09e9de3356b6e68cc2ae5d70f"},
+ {file = "hypothesis-6.157.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96abb36b598b1b9a427716e28ddccb4b88fd4be462e92d23c3c12e1f91ee3efd"},
+ {file = "hypothesis-6.157.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db9124511b4bde227e927d2e3eb43a2df4416930c47874126a8d71ea13e3fe2b"},
+ {file = "hypothesis-6.157.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b7b53bf761fac490c758db65062072b2239b70e4f3ac772439aa73186171ffd7"},
+ {file = "hypothesis-6.157.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dc4cfa9feb2cb7d3ff327b2298711e5b61fbdf1f95617344ae6de8ef25563c88"},
+ {file = "hypothesis-6.157.0-cp310-cp310-win_amd64.whl", hash = "sha256:89c0c6e3426eb4ebe721c80edf19285e8979a119e2b77971c5d8e959efeddbd9"},
+ {file = "hypothesis-6.157.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d73b30b5d5904493cf6c2fd22ed202e9f05537c9da8e8f1c985c52abe118d35d"},
+ {file = "hypothesis-6.157.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:856ee6862ba535820117d3f042ccdc078eee30915d5a79af94fb97ac604cf7aa"},
+ {file = "hypothesis-6.157.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc9c695dab6573d27c0f1482937b2df963c24e08a75f6cdc9eb3aa32efb183bf"},
+ {file = "hypothesis-6.157.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89d41263f048b05e3723c66d10ce7e9ff7e73931dadf5e7cfab0dade18c1abd1"},
+ {file = "hypothesis-6.157.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:26d7f7f95b3d93b7930f9e0a13ae93bc75832c97eea7b76c966aa8799ba0f2ab"},
+ {file = "hypothesis-6.157.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:00701c60761391eb4b11ed469a265ea2957fe49bcdc3b4920ce527589cef11e9"},
+ {file = "hypothesis-6.157.0-cp311-cp311-win_amd64.whl", hash = "sha256:f2e2e18aa5c8bf8e8046e4bef5cc6c58bf72a0a57e15d75abf519060ea37cdec"},
+ {file = "hypothesis-6.157.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:cc840c8878e80f3a2a3bc273a2a16027925585a19ce8ef0e21d43900c855b72f"},
+ {file = "hypothesis-6.157.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c13eeeb7ac9c493e3985d5190ffda8779d9f50cdd7f5ca2cb13bb55c4f885cef"},
+ {file = "hypothesis-6.157.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99e5c77e29000033412ef56ffe6900d1cb396c60860b69794d35d535350d3151"},
+ {file = "hypothesis-6.157.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b84a473dc19e5bdd26fff44c08b33ef9c342a59da90466cdf31400e812e2feae"},
+ {file = "hypothesis-6.157.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:78efc2441fded9c12de7db6d38b64af2173af49c0f09d09acdb0ad37028f6044"},
+ {file = "hypothesis-6.157.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1f961c45fb9908966bef523d42b5c018896cfcb1dc4faee70cdcb03c55de0f14"},
+ {file = "hypothesis-6.157.0-cp312-cp312-win_amd64.whl", hash = "sha256:848f5c0349a2629f13c40493f0c1ad380e92a31edb7b76646ba58b1268f29d7c"},
+ {file = "hypothesis-6.157.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:84a93415a8d0c69ef7d37548c653cd1f4cbc861457f29ca5ecfeb9ab21955195"},
+ {file = "hypothesis-6.157.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cea91fe827de004963e611697c52ee2f766cc4366183a822d9ed1470c2bdc0a9"},
+ {file = "hypothesis-6.157.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feb9af5c6ea644b136a871bab2a43930836445cf1feae29c9e05ae0173439668"},
+ {file = "hypothesis-6.157.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce0e0bfe1e9c849d96850d23eb5cf02d80d278d2a5130cabe7815c13c8aea15e"},
+ {file = "hypothesis-6.157.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae9064b021a0571514db40f2beb99a31c8d18a1fd12f0ee30cb7e72c102483e4"},
+ {file = "hypothesis-6.157.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84b664f0500911713f564ec826fe29ba883a635921babc2683a4fd12cb567988"},
+ {file = "hypothesis-6.157.0-cp313-cp313-win_amd64.whl", hash = "sha256:2fabcd93730584a28b0324da308122f9cb6f35d2d540d223d5561649d71b5392"},
+ {file = "hypothesis-6.157.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c3084b75511d50a912eb597ab5d1d19a12734a38155c93b061a6beead84a55cd"},
+ {file = "hypothesis-6.157.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:46519de255107a12213470aebea23fb457acec1157ca9aafc2ce4278d073d61e"},
+ {file = "hypothesis-6.157.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:702c39588097cdaacbbd9b3c7c26abc2321a91f023163af364698d79d57adcd2"},
+ {file = "hypothesis-6.157.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f1f48231ba294c0eff490403ff1580c5ba8534679ee5462171f7c23a7096592"},
+ {file = "hypothesis-6.157.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcc1cf1d961e9fa4043e5938ebee8b81de559c5253253ef42a523bb73f9a6be4"},
+ {file = "hypothesis-6.157.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:48a75902f2a5b6fbf8b3e22e5d1c9c099ecb1accb1b32860dbc07ef8b66d5cc5"},
+ {file = "hypothesis-6.157.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a48197661d22f0a6d6fad9ae43fc630901c996e7914e8a22eb0fa3c041295b0c"},
+ {file = "hypothesis-6.157.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8d7c38f06cd43f68eca7532fc55171b265c3b55cf29317e7d330fbdfdb19ab"},
+ {file = "hypothesis-6.157.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a32ae18769c095331eedd1921e999075660631d532370497116dc5dc1975024a"},
+ {file = "hypothesis-6.157.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d6947a4978496aeafc94815b16643a3aa753e12da090ccee65bf2f72c29b0b96"},
+ {file = "hypothesis-6.157.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c32d9da2d7f52d6cd04cfaaab6e32793c99525f07c5cb44a04c3895df73506b"},
+ {file = "hypothesis-6.157.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9404b73097ef8deb2f0a72d7a8c38c15c86f4aa3d2fcad89540e17c33eb6259"},
+ {file = "hypothesis-6.157.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b97ebad2018f39287b5c8fdfd66d4372c005bc914fe2db6c283facbac7ddb81"},
+ {file = "hypothesis-6.157.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cb4670b6f9a2c863025d9c4a8e7307bf083bce391e37dcb8857d9ec1a422ae5e"},
+ {file = "hypothesis-6.157.0-cp314-cp314t-win_amd64.whl", hash = "sha256:42861f32c1ca6fb999528a5feb553a967fc6b18c39411a224002cdf0d6e91364"},
+ {file = "hypothesis-6.157.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:aabe6c3f74bac72be3908581be071a90c21fb2a6d99a264363710783cfd072c5"},
+ {file = "hypothesis-6.157.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5ffb93da23a43edd894b4cbec3af5f8975cc53def3df345d4ca9f5549939e2d7"},
+ {file = "hypothesis-6.157.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bff02bff2b8cb0d464cfbe62aacca26838155de3ac8f8c6afd697d1866722e8"},
+ {file = "hypothesis-6.157.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19d9bc11c4e56ac0b3709e78589d9d94156c1649fe371849e02940d474a71755"},
+ {file = "hypothesis-6.157.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c0f75a017cc914f8aa4ae11c210a6de8592501e34a0697a24c349069c16b33dd"},
+ {file = "hypothesis-6.157.0.tar.gz", hash = "sha256:5e4cd0af9261b06fc79e8aabe7d840384b3c24eaceae7e7e25ee3800a6d6ac58"},
+]
+
+[package.dependencies]
+exceptiongroup = {version = ">=1.0.0", markers = "python_full_version < \"3.11.0\""}
+sortedcontainers = ">=2.1.0,<3.0.0"
+
+[package.extras]
+all = ["black (>=20.8b0)", "click (>=7.0)", "crosshair-tool (>=0.0.108)", "django (>=5.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.28)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.21.6)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2026.3) ; sys_platform == \"emscripten\" or sys_platform == \"win32\"", "watchdog (>=4.0.0)"]
+cli = ["black (>=20.8b0)", "click (>=7.0)", "rich (>=9.0.0)"]
+codemods = ["libcst (>=0.3.16)"]
+crosshair = ["crosshair-tool (>=0.0.108)", "hypothesis-crosshair (>=0.0.28)"]
+dateutil = ["python-dateutil (>=1.4)"]
+django = ["django (>=5.2)"]
+dpcontracts = ["dpcontracts (>=0.4)"]
+ghostwriter = ["black (>=20.8b0)"]
+lark = ["lark (>=0.10.1)"]
+numpy = ["numpy (>=1.21.6)"]
+pandas = ["pandas (>=1.1)"]
+pytest = ["pytest (>=4.6)"]
+pytz = ["pytz (>=2014.1)"]
+redis = ["redis (>=3.0.0)"]
+watchdog = ["watchdog (>=4.0.0)"]
+zoneinfo = ["tzdata (>=2026.3) ; sys_platform == \"emscripten\" or sys_platform == \"win32\""]
+
+[[package]]
+name = "idna"
+version = "3.18"
+description = "Internationalized Domain Names in Applications (IDNA)"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"},
+ {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"},
+]
+
+[package.extras]
+all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
+ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"},
+ {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"},
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+description = "plugin and hook calling mechanisms for python"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
+ {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["coverage", "pytest", "pytest-benchmark"]
+
+[[package]]
+name = "priority"
+version = "2.0.0"
+description = "A pure-Python implementation of the HTTP/2 priority tree"
+optional = false
+python-versions = ">=3.6.1"
+groups = ["main"]
+files = [
+ {file = "priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa"},
+ {file = "priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0"},
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+description = "C parser in Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""
+files = [
+ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"},
+ {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"},
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+description = "Pygments is a syntax highlighting package written in Python."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
+ {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
+]
+
+[package.extras]
+windows-terminal = ["colorama (>=0.4.6)"]
+
+[[package]]
+name = "pylsqpack"
+version = "0.3.24"
+description = "Python wrapper for the ls-qpack QPACK library"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "pylsqpack-0.3.24-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:8edf48d0a023cd3629b2c4aaccac9b79a46d566c0f61e7416b5678228433763d"},
+ {file = "pylsqpack-0.3.24-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e7d956dbc8f7d597b237b9157d0a16bc7c655a1b031239763c18dc8582aff8cc"},
+ {file = "pylsqpack-0.3.24-cp310-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b6a8bb42127d5ece8d301a673c8205df25b73b69f8c46b9f0c3034588de1789a"},
+ {file = "pylsqpack-0.3.24-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3f977d419c60c1d6c2240e6d7a52df820d37eb8c36b4057113bcd7859f53e2c"},
+ {file = "pylsqpack-0.3.24-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6024854eb16d32803d4890fb90a73b9348c74b61c0770680aefaaa75f8456e8c"},
+ {file = "pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54978a9879471596d84bbad5e67d727014048926bc5bb2dac0eb3701b48c5ac9"},
+ {file = "pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:caf63ddc2e581c764d17432893acce02c5c29ff879d77c2abf1e26aa4eeb831b"},
+ {file = "pylsqpack-0.3.24-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc5f146fd456b50b227858aed59faa0ff8445aa426e69bb4e50d46c487aab0"},
+ {file = "pylsqpack-0.3.24-cp310-abi3-win32.whl", hash = "sha256:8da12be7b35b7c9a8cf73a4c077f72e5022a311f80a401c79904213376f2d767"},
+ {file = "pylsqpack-0.3.24-cp310-abi3-win_amd64.whl", hash = "sha256:c3e2327af25ee616ce4483a8748f0957cf017cbca82d58ed15efea68f70f94ff"},
+ {file = "pylsqpack-0.3.24-cp310-abi3-win_arm64.whl", hash = "sha256:23b4d8af48836893beac356c10ca268161953de5bf9ed691526a93f5c82433e9"},
+ {file = "pylsqpack-0.3.24.tar.gz", hash = "sha256:8ec455f44614228f89e38d40c1b1e37895620e20ec6b21e3b562fa8b79a23890"},
+]
+
+[[package]]
+name = "pyopenssl"
+version = "26.3.0"
+description = "Python wrapper module around the OpenSSL library"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "pyopenssl-26.3.0-py3-none-any.whl", hash = "sha256:46367f8f66b92271e6d218da9c87607e1ef5a0bc5c8dea5bb3db82f395c385a3"},
+ {file = "pyopenssl-26.3.0.tar.gz", hash = "sha256:589de7fae1c9ea670d18422ed00fc04da787bbde8e1454aea872aa57b49ad341"},
+]
+
+[package.dependencies]
+cryptography = ">=49.0.0,<50"
+typing-extensions = {version = ">=4.9", markers = "python_version < \"3.13\""}
+
+[package.extras]
+docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx_rtd_theme"]
+test = ["pretend", "pytest (>=3.0.1)", "pytest-rerunfailures"]
+
+[[package]]
+name = "pyserial"
+version = "3.5"
+description = "Python Serial Port Extension"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"},
+ {file = "pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb"},
+]
+
+[package.extras]
+cp2110 = ["hidapi"]
+
+[[package]]
+name = "pytest"
+version = "9.1.1"
+description = "pytest: simple powerful testing with Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"},
+ {file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"},
+]
+
+[package.dependencies]
+colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
+exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""}
+iniconfig = ">=1.0.1"
+packaging = ">=22"
+pluggy = ">=1.5,<2"
+pygments = ">=2.7.2"
+tomli = {version = ">=1", markers = "python_version < \"3.11\""}
+
+[package.extras]
+dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "pytest-timeout"
+version = "2.4.0"
+description = "pytest plugin to abort hanging tests"
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2"},
+ {file = "pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a"},
+]
+
+[package.dependencies]
+pytest = ">=7.0.0"
+
+[[package]]
+name = "rns"
+version = "1.3.9"
+description = "Self-configuring, encrypted and resilient mesh networking stack for LoRa, packet radio, WiFi and everything in between"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "rns-1.3.9-py3-none-any.whl", hash = "sha256:ceeac4799dda72b48a34b1e36de538e64009cba5b7b28d086918c0120ca8bfa4"},
+ {file = "rns-1.3.9.tar.gz", hash = "sha256:fd3745c1aba4dcbf8833fa87a3157ec9900745a32d6d96ba330d623a98932a15"},
+]
+
+[package.dependencies]
+cryptography = ">=3.4.7"
+pyserial = ">=3.5"
+
+[[package]]
+name = "service-identity"
+version = "26.1.0"
+description = "Service identity verification for pyOpenSSL & cryptography."
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "service_identity-26.1.0-py3-none-any.whl", hash = "sha256:68c32dadbb69135fb951077677e07cd7f6031020f3a8c8f47a28cda8a0742118"},
+ {file = "service_identity-26.1.0.tar.gz", hash = "sha256:6358c52882c96e66ac4a55eb3a72c7dd4a70763f8cc6fa4e70abde2656f4bf3b"},
+]
+
+[package.dependencies]
+attrs = ">=19.1.0"
+cryptography = ">=47"
+
+[package.extras]
+idna = ["idna"]
+
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
+optional = false
+python-versions = "*"
+groups = ["dev"]
+files = [
+ {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"},
+ {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
+]
+
+[[package]]
+name = "taskgroup"
+version = "0.2.2"
+description = "backport of asyncio.TaskGroup, asyncio.Runner and asyncio.timeout"
+optional = false
+python-versions = "*"
+groups = ["main"]
+markers = "python_version == \"3.10\""
+files = [
+ {file = "taskgroup-0.2.2-py2.py3-none-any.whl", hash = "sha256:e2c53121609f4ae97303e9ea1524304b4de6faf9eb2c9280c7f87976479a52fb"},
+ {file = "taskgroup-0.2.2.tar.gz", hash = "sha256:078483ac3e78f2e3f973e2edbf6941374fbea81b9c5d0a96f51d297717f4752d"},
+]
+
+[package.dependencies]
+exceptiongroup = "*"
+typing_extensions = ">=4.12.2,<5"
+
+[[package]]
+name = "tomli"
+version = "2.4.1"
+description = "A lil' TOML parser"
+optional = false
+python-versions = ">=3.8"
+groups = ["main", "dev"]
+markers = "python_version == \"3.10\""
+files = [
+ {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"},
+ {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"},
+ {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"},
+ {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"},
+ {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"},
+ {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"},
+ {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"},
+ {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"},
+ {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"},
+ {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"},
+ {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"},
+ {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"},
+ {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"},
+ {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"},
+ {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"},
+ {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"},
+ {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"},
+ {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"},
+ {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"},
+ {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"},
+ {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"},
+ {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"},
+ {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"},
+ {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"},
+ {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"},
+ {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"},
+ {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"},
+ {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"},
+ {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"},
+ {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"},
+ {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"},
+ {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"},
+ {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"},
+ {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"},
+ {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"},
+ {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"},
+ {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"},
+ {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"},
+ {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"},
+ {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"},
+ {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"},
+ {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"},
+ {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"},
+ {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"},
+ {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"},
+ {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"},
+ {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"},
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+description = "Backported and Experimental Type Hints for Python 3.9+"
+optional = false
+python-versions = ">=3.9"
+groups = ["main", "dev"]
+files = [
+ {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"},
+ {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"},
+]
+markers = {main = "python_version < \"3.13\"", dev = "python_version == \"3.10\""}
+
+[[package]]
+name = "wsproto"
+version = "1.3.2"
+description = "Pure-Python WebSocket protocol implementation"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584"},
+ {file = "wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294"},
+]
+
+[package.dependencies]
+h11 = ">=0.16.0,<1"
+
+[metadata]
+lock-version = "2.1"
+python-versions = "^3.10"
+content-hash = "40f2b9d0b11cbd12d7033234c9fe18b08045ffec7741140a9b65dbe2e95bf877"

diff --git a/vendor/rns_over_http/pyproject.toml b/vendor/rns_over_http/pyproject.toml
new file mode 100644
index 00000000..d3214d4b
--- /dev/null
+++ b/vendor/rns_over_http/pyproject.toml
@@ -0,0 +1,33 @@
+[tool.poetry]
+name = "rns-over-http"
+version = "0.1.0"
+description = "Reticulum interface that tunnels RNS traffic over HTTP POST"
+authors = ["Ivan / Quad4.io"]
+license = "MIT"
+readme = "README.md"
+package-mode = false
+
+[tool.poetry.dependencies]
+python = "^3.10"
+rns = "^1.3.9"
+httpx = {extras = ["http2"], version = "^0.28.1"}
+hypercorn = {extras = ["h3"], version = "^0.18.0"}
+aioquic = "^1.3.0"
+
+[tool.poetry.group.dev.dependencies]
+pytest = "^9.0.0"
+hypothesis = "^6.0.0"
+pytest-timeout = "^2.0.0"
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
+
+[tool.pytest.ini_options]
+pythonpath = ["."]
+testpaths = ["tests"]
+timeout = 120
+timeout_method = "thread"
+markers = [
+ "live: full Reticulum live tests (slower, starts local rns instances)",
+]

diff --git a/vendor/rns_over_http/tests/_live_rns_peer.py b/vendor/rns_over_http/tests/_live_rns_peer.py
new file mode 100644
index 00000000..6cbd7ac8
--- /dev/null
+++ b/vendor/rns_over_http/tests/_live_rns_peer.py
@@ -0,0 +1,186 @@
+#!/usr/bin/env python3
+"""Subprocess helper for live Reticulum HTTP tunnel tests."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+import time
+from pathlib import Path
+
+
+def write_json(path: Path, obj):
+ path.write_text(json.dumps(obj) + "\n", encoding="utf-8")
+
+
+def cmd_serve(args):
+ import RNS
+
+ RNS.Reticulum(
+ configdir=args.configdir,
+ loglevel=RNS.LOG_NOTICE,
+ )
+ identity = RNS.Identity()
+ destination = RNS.Destination(
+ identity,
+ RNS.Destination.IN,
+ RNS.Destination.SINGLE,
+ "http_live",
+ "echo",
+ )
+
+ received = {"payload": None}
+
+ def packet_callback(data, packet):
+ try:
+ text = data.decode("utf-8")
+ except Exception:
+ text = None
+ received["payload"] = text
+ write_json(Path(args.recv_file), {"payload": text})
+
+ destination.set_packet_callback(packet_callback)
+ destination.announce()
+ Path(args.announce_file).write_text(destination.hash.hex() + "\n", encoding="utf-8")
+
+ deadline = time.monotonic() + args.timeout
+ while time.monotonic() < deadline:
+ if received["payload"] is not None:
+ time.sleep(1.0)
+ return 0
+ time.sleep(0.1)
+
+ write_json(Path(args.recv_file), {"payload": None, "error": "timeout"})
+ return 1
+
+
+def cmd_client(args):
+ import RNS
+
+ reticulum = RNS.Reticulum(
+ configdir=args.configdir,
+ loglevel=RNS.LOG_NOTICE,
+ )
+ RNS.Identity()
+ target_hash = bytes.fromhex(args.target_hash)
+
+ if not RNS.Transport.has_path(target_hash):
+ RNS.Transport.request_path(target_hash)
+
+ deadline = time.monotonic() + args.timeout
+ while time.monotonic() < deadline:
+ if RNS.Transport.has_path(target_hash):
+ break
+ RNS.Transport.request_path(target_hash)
+ time.sleep(0.25)
+ else:
+ write_json(
+ Path(args.done_file),
+ {"ok": False, "error": "path_timeout"},
+ )
+ return 1
+
+ recalled = RNS.Identity.recall(target_hash)
+ if recalled is None:
+ write_json(
+ Path(args.done_file),
+ {"ok": False, "error": "identity_recall_failed"},
+ )
+ return 1
+
+ dest = RNS.Destination(
+ recalled,
+ RNS.Destination.OUT,
+ RNS.Destination.SINGLE,
+ "http_live",
+ "echo",
+ )
+ packet = RNS.Packet(dest, args.payload.encode("utf-8"))
+ packet.send()
+
+ write_json(
+ Path(args.done_file),
+ {
+ "ok": True,
+ "path": True,
+ "shared": reticulum.is_shared_instance,
+ },
+ )
+ time.sleep(3.0)
+ return 0
+
+
+def cmd_local_client(args):
+ import RNS
+
+ reticulum = RNS.Reticulum(
+ configdir=args.configdir,
+ loglevel=RNS.LOG_NOTICE,
+ require_shared_instance=True,
+ )
+
+ connected = reticulum.is_connected_to_shared_instance
+ expect = bytes.fromhex(args.expect_hash)
+
+ deadline = time.monotonic() + min(args.timeout, 20.0)
+ has_path = False
+ while time.monotonic() < deadline:
+ if RNS.Transport.has_path(expect):
+ has_path = True
+ break
+ RNS.Transport.request_path(expect)
+ time.sleep(0.3)
+
+ ok = bool(connected)
+ write_json(
+ Path(args.done_file),
+ {
+ "ok": ok,
+ "connected_to_shared": connected,
+ "has_path": has_path,
+ "is_shared_instance": reticulum.is_shared_instance,
+ },
+ )
+ return 0 if ok else 1
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ sub = parser.add_subparsers(dest="cmd", required=True)
+
+ p_serve = sub.add_parser("serve")
+ p_serve.add_argument("--configdir", required=True)
+ p_serve.add_argument("--announce-file", required=True)
+ p_serve.add_argument("--recv-file", required=True)
+ p_serve.add_argument("--timeout", type=float, default=120.0)
+ p_serve.set_defaults(func=cmd_serve)
+
+ p_client = sub.add_parser("client")
+ p_client.add_argument("--configdir", required=True)
+ p_client.add_argument("--target-hash", required=True)
+ p_client.add_argument("--done-file", required=True)
+ p_client.add_argument("--payload", required=True)
+ p_client.add_argument("--timeout", type=float, default=90.0)
+ p_client.set_defaults(func=cmd_client)
+
+ p_local = sub.add_parser("local_client")
+ p_local.add_argument("--configdir", required=True)
+ p_local.add_argument("--done-file", required=True)
+ p_local.add_argument("--expect-hash", required=True)
+ p_local.add_argument("--timeout", type=float, default=45.0)
+ p_local.set_defaults(func=cmd_local_client)
+
+ args = parser.parse_args()
+ try:
+ return args.func(args)
+ except Exception as exc:
+ print(f"peer error: {exc}", file=sys.stderr)
+ import traceback
+
+ traceback.print_exc()
+ return 2
+
+
+if __name__ == "__main__":
+ sys.exit(main())

diff --git a/vendor/rns_over_http/tests/conftest.py b/vendor/rns_over_http/tests/conftest.py
new file mode 100644
index 00000000..cfeb4bc9
--- /dev/null
+++ b/vendor/rns_over_http/tests/conftest.py
@@ -0,0 +1,45 @@
+import socket
+import textwrap
+
+import pytest
+
+
+def _free_tcp_port():
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+@pytest.fixture(scope="session", autouse=True)
+def reticulum_bootstrap(tmp_path_factory):
+ """RNS 1.3+ Interface.__init__ requires an active Reticulum instance."""
+ import RNS
+
+ configdir = tmp_path_factory.mktemp("rns_bootstrap")
+ shared_port = _free_tcp_port()
+ control_port = _free_tcp_port()
+ (configdir / "interfaces").mkdir()
+ (configdir / "config").write_text(
+ textwrap.dedent(
+ f"""
+ [reticulum]
+ enable_transport = No
+ share_instance = No
+ instance_name = http-tunnel-pytest
+ shared_instance_port = {shared_port}
+ instance_control_port = {control_port}
+ shared_instance_type = tcp
+
+ [logging]
+ loglevel = 0
+
+ [interfaces]
+ [[Default Interface]]
+ type = AutoInterface
+ enabled = No
+ """
+ ),
+ encoding="utf-8",
+ )
+ reticulum = RNS.Reticulum(configdir=str(configdir), loglevel=RNS.LOG_CRITICAL)
+ yield reticulum

diff --git a/vendor/rns_over_http/tests/test_hdlc_framing.py b/vendor/rns_over_http/tests/test_hdlc_framing.py
new file mode 100644
index 00000000..7c2cb2b7
--- /dev/null
+++ b/vendor/rns_over_http/tests/test_hdlc_framing.py
@@ -0,0 +1,111 @@
+import random
+
+from hypothesis import given, settings, strategies as st
+
+from HTTPInterface import HDLC
+
+
+def test_frame_wraps_with_flags():
+ framed = HDLC.frame(b"abc")
+ assert framed[0] == HDLC.FLAG
+ assert framed[-1] == HDLC.FLAG
+ assert bytes([HDLC.FLAG]) not in framed[1:-1]
+
+
+def test_escape_flag_and_esc_inside_payload():
+ payload = bytes([0x01, HDLC.FLAG, 0x02, HDLC.ESC, 0x03])
+ framed = HDLC.frame(payload)
+ assert bytes([HDLC.ESC, HDLC.FLAG ^ HDLC.ESC_MASK]) in framed
+ assert bytes([HDLC.ESC, HDLC.ESC ^ HDLC.ESC_MASK]) in framed
+ packets, rem = HDLC.deframe(framed, 4096)
+ assert rem == b""
+ assert packets == [payload]
+
+
+def test_deframe_multiple_packets():
+ wire = HDLC.frame(b"one") + HDLC.frame(b"two") + HDLC.frame(b"three")
+ packets, rem = HDLC.deframe(wire, 4096)
+ assert rem == b""
+ assert packets == [b"one", b"two", b"three"]
+
+
+def test_deframe_incomplete_returns_remainder():
+ partial = HDLC.frame(b"complete") + bytes([HDLC.FLAG]) + b"incomplete"
+ packets, rem = HDLC.deframe(partial, 4096)
+ assert packets == [b"complete"]
+ assert rem.startswith(bytes([HDLC.FLAG]))
+ assert b"incomplete" in rem
+
+
+def test_deframe_split_across_chunks():
+ wire = HDLC.frame(b"split-me")
+ mid = len(wire) // 2
+ first, rem1 = HDLC.deframe(wire[:mid], 4096)
+ assert first == []
+ assert rem1 != b""
+ second, rem2 = HDLC.deframe(rem1 + wire[mid:], 4096)
+ assert second == [b"split-me"]
+ assert rem2 == b""
+
+
+def test_empty_frame_yields_empty_packet():
+ packets, rem = HDLC.deframe(bytes([HDLC.FLAG, HDLC.FLAG]), 4096)
+ assert packets == [b""]
+ assert rem == b""
+
+
+@given(st.binary(min_size=0, max_size=512))
+@settings(max_examples=200, deadline=None)
+def test_fuzz_roundtrip_single_packet(data):
+ framed = HDLC.frame(data)
+ packets, rem = HDLC.deframe(framed, max(len(data) + 16, 64))
+ assert rem == b""
+ assert packets == [data]
+
+
+@given(st.lists(st.binary(min_size=0, max_size=128), min_size=1, max_size=20))
+@settings(max_examples=100, deadline=None)
+def test_fuzz_roundtrip_batch(packets_in):
+ wire = b"".join(HDLC.frame(p) for p in packets_in)
+ packets, rem = HDLC.deframe(wire, 4096)
+ assert rem == b""
+ assert packets == packets_in
+
+
+@given(st.binary(min_size=1, max_size=256), st.integers(min_value=1, max_value=8))
+@settings(max_examples=100, deadline=None)
+def test_fuzz_chunked_reassembly(data, chunks):
+ wire = HDLC.frame(data)
+ remainder = b""
+ out = []
+ step = max(1, len(wire) // chunks)
+ for i in range(0, len(wire), step):
+ piece = remainder + wire[i : i + step]
+ pkts, remainder = HDLC.deframe(piece, 4096)
+ out.extend(pkts)
+ if remainder:
+ pkts, remainder = HDLC.deframe(remainder, 4096)
+ out.extend(pkts)
+ assert remainder == b""
+ assert out == [data]
+
+
+def test_random_noise_without_flags_still_recovers_frame():
+ rng = random.Random(42)
+ for _ in range(50):
+ noise = bytearray()
+ while len(noise) < rng.randint(0, 64):
+ b = rng.getrandbits(8)
+ if b not in (HDLC.FLAG, HDLC.ESC):
+ noise.append(b)
+ payload = bytes(rng.getrandbits(8) for _ in range(rng.randint(1, 80)))
+ wire = bytes(noise) + HDLC.frame(payload)
+ packets, rem = HDLC.deframe(wire, 4096)
+ assert rem == b""
+ assert packets == [payload]
+
+
+def test_pipe_compatible_escape_constants():
+ assert HDLC.FLAG == 0x7E
+ assert HDLC.ESC == 0x7D
+ assert HDLC.ESC_MASK == 0x20

diff --git a/vendor/rns_over_http/tests/test_http2_http3.py b/vendor/rns_over_http/tests/test_http2_http3.py
new file mode 100644
index 00000000..d8238c46
--- /dev/null
+++ b/vendor/rns_over_http/tests/test_http2_http3.py
@@ -0,0 +1,281 @@
+"""HTTP/2 and HTTP/3 tunnel tests over TLS."""
+
+from __future__ import annotations
+
+import io
+import socket
+import time
+
+import httpx
+import pytest
+from RNS.vendor.configobj import ConfigObj
+
+import HTTPInterface as http_mod
+from HTTPInterface import HDLC, HTTPTunnelInterface
+from tests.tls_certs import write_self_signed_cert
+
+
+def free_tcp_port():
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+def wait_tcp_open(host, port, timeout=15.0):
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ try:
+ with socket.create_connection((host, port), timeout=0.3):
+ return
+ except OSError:
+ time.sleep(0.05)
+ pytest.fail(f"port {host}:{port} did not open within {timeout}s")
+
+
+def wait_udp_bound(host, port, timeout=15.0):
+ """Best-effort wait until something is listening on UDP port."""
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ # Hypercorn QUIC bind is harder to probe; give the ASGI thread time.
+ time.sleep(0.1)
+ # Also accept TCP (Hypercorn binds TCP even for HTTP/3 mode).
+ try:
+ with socket.create_connection((host, port), timeout=0.2):
+ return
+ except OSError:
+ continue
+ pytest.fail(f"HTTP/3 server did not become ready on {host}:{port}")
+
+
+def configobj_from_lines(lines):
+ return ConfigObj(io.StringIO("\n".join(lines) + "\n"))
+
+
+class RecordingOwner:
+ def __init__(self):
+ self.packets = []
+
+ def inbound(self, data, iface):
+ self.packets.append(bytes(data))
+
+ def last_payload(self, timeout=10.0, poll=0.05):
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if self.packets:
+ return self.packets[-1]
+ time.sleep(poll)
+ return None
+
+ def wait_count(self, n, timeout=15.0):
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if len(self.packets) >= n:
+ return list(self.packets)
+ time.sleep(0.05)
+ return None
+
+
+def make_tls_pair(tmp_path, free_port, http_version, poll_interval=0.05):
+ cert, key = write_self_signed_cert(tmp_path / f"certs-h{http_version}")
+ owner_s = RecordingOwner()
+ owner_c = RecordingOwner()
+ server = HTTPTunnelInterface(
+ owner_s,
+ configobj_from_lines(
+ [
+ "name = srv",
+ "mode = server",
+ "listen_host = 127.0.0.1",
+ f"listen_port = {free_port}",
+ f"http_version = {http_version}",
+ f"tls_certfile = {cert}",
+ f"tls_keyfile = {key}",
+ f"poll_interval = {poll_interval}",
+ ]
+ ),
+ )
+ if http_version == 3:
+ wait_udp_bound("127.0.0.1", free_port)
+ else:
+ wait_tcp_open("127.0.0.1", free_port)
+
+ # Extra settle time for Hypercorn TLS/QUIC startup
+ time.sleep(0.3)
+
+ url = f"https://127.0.0.1:{free_port}/"
+ client = HTTPTunnelInterface(
+ owner_c,
+ configobj_from_lines(
+ [
+ "name = cli",
+ "mode = client",
+ f"server_url = {url}",
+ f"http_version = {http_version}",
+ "tls_verify = false",
+ f"poll_interval = {poll_interval}",
+ ]
+ ),
+ )
+ return owner_s, owner_c, server, client
+
+
+@pytest.fixture
+def free_port():
+ return free_tcp_port()
+
+
+def test_http2_requires_tls_on_server(free_port):
+ with pytest.raises(ValueError, match="tls_certfile"):
+ HTTPTunnelInterface(
+ RecordingOwner(),
+ configobj_from_lines(
+ [
+ "name = srv",
+ "mode = server",
+ "listen_host = 127.0.0.1",
+ f"listen_port = {free_port}",
+ "http_version = 2",
+ ]
+ ),
+ )
+
+
+def test_http3_client_requires_https_url():
+ with pytest.raises(ValueError, match="https://"):
+ HTTPTunnelInterface(
+ RecordingOwner(),
+ configobj_from_lines(
+ [
+ "name = cli",
+ "mode = client",
+ "server_url = http://127.0.0.1:9/",
+ "http_version = 3",
+ ]
+ ),
+ )
+
+
+def test_invalid_http_version_rejected(free_port):
+ with pytest.raises(ValueError, match="http_version"):
+ HTTPTunnelInterface(
+ RecordingOwner(),
+ configobj_from_lines(
+ [
+ "name = srv",
+ "mode = server",
+ "listen_host = 127.0.0.1",
+ f"listen_port = {free_port}",
+ "http_version = 4",
+ ]
+ ),
+ )
+
+
+def test_http2_bidirectional_tunnel(tmp_path, free_port):
+ owner_s, owner_c, server, client = make_tls_pair(tmp_path, free_port, 2)
+ try:
+ client.process_outgoing(b"h2-client")
+ assert owner_s.last_payload(timeout=15.0) == b"h2-client"
+
+ server.process_outgoing(b"h2-server")
+ assert owner_c.last_payload(timeout=15.0) == b"h2-server"
+
+ # Confirm httpx negotiated HTTP/2
+ deadline = time.monotonic() + 5.0
+ while time.monotonic() < deadline:
+ if client.connection_stats().get("last_http_version") == "HTTP/2":
+ break
+ time.sleep(0.05)
+ assert client.connection_stats()["last_http_version"] == "HTTP/2"
+ assert server.connection_stats()["http_requests"] >= 1
+ finally:
+ client.detach()
+ server.detach()
+
+
+def test_http2_reuses_connection_across_polls(tmp_path, free_port):
+ owner_s, owner_c, server, client = make_tls_pair(tmp_path, free_port, 2)
+ try:
+ for i in range(6):
+ client.process_outgoing(f"h2p-{i}".encode())
+ payloads = owner_s.wait_count(6, timeout=20.0)
+ assert payloads is not None
+ assert payloads[-6:] == [f"h2p-{i}".encode() for i in range(6)]
+ stats = client.connection_stats()
+ # Packets may be batched into fewer HTTP exchanges on one h2 connection
+ assert stats["client_requests"] >= 1
+ assert stats["client_requests"] <= 6
+ assert stats["last_http_version"] == "HTTP/2"
+ finally:
+ client.detach()
+ server.detach()
+
+
+def test_http2_external_client_sees_h2(tmp_path, free_port):
+ cert, key = write_self_signed_cert(tmp_path / "certs-ext")
+ server = HTTPTunnelInterface(
+ RecordingOwner(),
+ configobj_from_lines(
+ [
+ "name = srv",
+ "mode = server",
+ "listen_host = 127.0.0.1",
+ f"listen_port = {free_port}",
+ "http_version = 2",
+ f"tls_certfile = {cert}",
+ f"tls_keyfile = {key}",
+ ]
+ ),
+ )
+ try:
+ wait_tcp_open("127.0.0.1", free_port)
+ time.sleep(0.3)
+ with httpx.Client(http2=True, verify=False, timeout=5.0) as c:
+ r = c.post(
+ f"https://127.0.0.1:{free_port}/",
+ content=HDLC.frame(b"ext-h2"),
+ headers={"User-Agent": HTTPTunnelInterface.TUNNEL_USER_AGENT},
+ )
+ assert r.status_code == 200
+ assert r.http_version == "HTTP/2"
+ finally:
+ server.detach()
+
+
+def test_http3_bidirectional_tunnel(tmp_path, free_port):
+ owner_s, owner_c, server, client = make_tls_pair(tmp_path, free_port, 3)
+ try:
+ client.process_outgoing(b"h3-client")
+ assert owner_s.last_payload(timeout=20.0) == b"h3-client"
+
+ server.process_outgoing(b"h3-server")
+ assert owner_c.last_payload(timeout=20.0) == b"h3-server"
+
+ assert client.connection_stats()["last_http_version"] == "HTTP/3"
+ assert client.connection_stats()["client_requests"] >= 1
+ assert server.connection_stats()["http_requests"] >= 1
+ finally:
+ client.detach()
+ server.detach()
+
+
+def test_http3_persistent_session_multiple_posts(tmp_path, free_port):
+ owner_s, owner_c, server, client = make_tls_pair(tmp_path, free_port, 3)
+ try:
+ for i in range(5):
+ client.process_outgoing(f"h3p-{i}".encode())
+ payloads = owner_s.wait_count(5, timeout=25.0)
+ assert payloads is not None
+ assert payloads[-5:] == [f"h3p-{i}".encode() for i in range(5)]
+ stats = client.connection_stats()
+ assert stats["pool_num_connections"] == 1
+ assert stats["pool_num_requests"] >= 1
+ assert stats["pool_num_requests"] <= 5
+ assert stats["last_http_version"] == "HTTP/3"
+ finally:
+ client.detach()
+ server.detach()
+
+
+def test_module_still_exports_interface_class():
+ assert http_mod.interface_class is HTTPTunnelInterface

diff --git a/vendor/rns_over_http/tests/test_http_tunnel_integration.py b/vendor/rns_over_http/tests/test_http_tunnel_integration.py
new file mode 100644
index 00000000..ba29c43d
--- /dev/null
+++ b/vendor/rns_over_http/tests/test_http_tunnel_integration.py
@@ -0,0 +1,556 @@
+import io
+import socket
+import threading
+import time
+
+import httpx
+import pytest
+from RNS.vendor.configobj import ConfigObj
+
+import HTTPInterface as http_mod
+from HTTPInterface import HDLC
+
+HTTPTunnelInterface = http_mod.HTTPTunnelInterface
+
+
+def free_tcp_port():
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+def wait_tcp_open(host, port, timeout=8.0):
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ try:
+ with socket.create_connection((host, port), timeout=0.3):
+ return
+ except OSError:
+ time.sleep(0.05)
+ pytest.fail(f"port {host}:{port} did not open within {timeout}s")
+
+
+def configobj_from_lines(lines):
+ return ConfigObj(io.StringIO("\n".join(lines) + "\n"))
+
+
+def ping_client_to_server(owner_s, client, token=b"\xfe\xd2tunnel-pingpytest"):
+ client.process_outgoing(token)
+ assert owner_s.last_payload(timeout=10.0) == token
+
+
+@pytest.fixture
+def free_port():
+ return free_tcp_port()
+
+
+class RecordingOwner:
+ def __init__(self):
+ self._lock = threading.Lock()
+ self.packets = []
+
+ def inbound(self, data, iface):
+ with self._lock:
+ self.packets.append((bytes(data), iface))
+
+ def last_payload(self, timeout=6.0, poll=0.05):
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ with self._lock:
+ if self.packets:
+ return self.packets[-1][0]
+ time.sleep(poll)
+ return None
+
+ def payloads(self):
+ with self._lock:
+ return [bytes(p) for p, _ in self.packets]
+
+ def packet_count(self):
+ with self._lock:
+ return len(self.packets)
+
+ def wait_packet_count_at_least(self, n, timeout=8.0, poll=0.05):
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ with self._lock:
+ if len(self.packets) >= n:
+ return [bytes(p) for p, _ in self.packets]
+ time.sleep(poll)
+ return None
+
+
+def make_pair(free_port, poll_interval=0.05, **extra_server):
+ owner_s = RecordingOwner()
+ owner_c = RecordingOwner()
+ srv_lines = [
+ "name = srv",
+ "mode = server",
+ "listen_host = 127.0.0.1",
+ f"listen_port = {free_port}",
+ f"poll_interval = {poll_interval}",
+ ]
+ for k, v in extra_server.items():
+ srv_lines.append(f"{k} = {v}")
+ server = HTTPTunnelInterface(owner_s, configobj_from_lines(srv_lines))
+ wait_tcp_open("127.0.0.1", free_port)
+ url = f"http://127.0.0.1:{free_port}/"
+ client = HTTPTunnelInterface(
+ owner_c,
+ configobj_from_lines(
+ [
+ "name = cli",
+ "mode = client",
+ f"server_url = {url}",
+ f"poll_interval = {poll_interval}",
+ ]
+ ),
+ )
+ return owner_s, owner_c, server, client
+
+
+def test_client_mode_requires_server_url():
+ with pytest.raises(ValueError, match="server_url"):
+ HTTPTunnelInterface(
+ RecordingOwner(),
+ configobj_from_lines(
+ [
+ "name = c",
+ "mode = client",
+ ]
+ ),
+ )
+
+
+def test_invalid_tunnel_mode():
+ with pytest.raises(ValueError, match="Invalid mode"):
+ HTTPTunnelInterface(
+ RecordingOwner(),
+ configobj_from_lines(
+ [
+ "name = x",
+ "mode = bridge",
+ "listen_host = 127.0.0.1",
+ "listen_port = 9",
+ ]
+ ),
+ )
+
+
+def test_process_outgoing_rejects_over_mtu(free_port):
+ owner = RecordingOwner()
+ cfg = configobj_from_lines(
+ [
+ "name = srv",
+ "mode = server",
+ "listen_host = 127.0.0.1",
+ f"listen_port = {free_port}",
+ "mtu = 32",
+ ]
+ )
+ iface = HTTPTunnelInterface(owner, cfg)
+ try:
+ wait_tcp_open("127.0.0.1", free_port)
+ payload = b"x" * 64
+ iface.process_outgoing(payload)
+ assert iface._send_queue.empty()
+ finally:
+ iface.detach()
+
+
+def test_client_payload_reaches_server_owner(free_port):
+ owner_s, owner_c, server, client = make_pair(free_port)
+ try:
+ payload = b"rnstest-bytes-1"
+ client.process_outgoing(payload)
+ got = owner_s.last_payload(timeout=8.0)
+ assert got == payload
+ finally:
+ client.detach()
+ server.detach()
+
+
+def test_server_payload_reaches_client_owner(free_port):
+ owner_s, owner_c, server, client = make_pair(free_port)
+ try:
+ ping_client_to_server(owner_s, client)
+ reply = b"rnstest-reply-2"
+ server.process_outgoing(reply)
+ got = owner_c.last_payload(timeout=8.0)
+ assert got == reply
+ finally:
+ client.detach()
+ server.detach()
+
+
+def test_server_rejects_wrong_user_agent(free_port):
+ owner = RecordingOwner()
+ cfg = configobj_from_lines(
+ [
+ "name = srv",
+ "mode = server",
+ "listen_host = 127.0.0.1",
+ f"listen_port = {free_port}",
+ "check_user_agent = true",
+ ]
+ )
+ iface = HTTPTunnelInterface(owner, cfg)
+ try:
+ wait_tcp_open("127.0.0.1", free_port)
+ r = httpx.post(
+ f"http://127.0.0.1:{free_port}/",
+ content=HDLC.frame(b"probe"),
+ headers={"User-Agent": "Mozilla/5.0"},
+ timeout=5,
+ )
+ assert r.status_code == 403
+ time.sleep(0.2)
+ assert owner.packets == []
+ finally:
+ iface.detach()
+
+
+def test_client_default_mode_requires_server_url_when_mode_omitted():
+ with pytest.raises(ValueError, match="server_url"):
+ HTTPTunnelInterface(
+ RecordingOwner(),
+ configobj_from_lines(
+ [
+ "name = c",
+ ]
+ ),
+ )
+
+
+def test_tunnel_mode_case_insensitive_SERVER(free_port):
+ cfg = configobj_from_lines(
+ [
+ "name = srv",
+ "mode = SERVER",
+ "listen_host = 127.0.0.1",
+ f"listen_port = {free_port}",
+ ]
+ )
+ iface = HTTPTunnelInterface(RecordingOwner(), cfg)
+ try:
+ wait_tcp_open("127.0.0.1", free_port)
+ assert iface.mode == "server"
+ finally:
+ iface.detach()
+
+
+def test_process_outgoing_exact_mtu_accepted(free_port):
+ owner = RecordingOwner()
+ mtu = 80
+ cfg = configobj_from_lines(
+ [
+ "name = srv",
+ "mode = server",
+ "listen_host = 127.0.0.1",
+ f"listen_port = {free_port}",
+ f"mtu = {mtu}",
+ ]
+ )
+ iface = HTTPTunnelInterface(owner, cfg)
+ try:
+ wait_tcp_open("127.0.0.1", free_port)
+ payload = b"p" * mtu
+ tx_before = iface.txb
+ iface.process_outgoing(payload)
+ assert not iface._send_queue.empty()
+ assert iface._send_queue.get_nowait() == payload
+ assert iface.txb == tx_before + mtu
+ finally:
+ iface.detach()
+
+
+def test_process_outgoing_when_offline_does_not_queue(free_port):
+ cfg = configobj_from_lines(
+ [
+ "name = srv",
+ "mode = server",
+ "listen_host = 127.0.0.1",
+ f"listen_port = {free_port}",
+ ]
+ )
+ iface = HTTPTunnelInterface(RecordingOwner(), cfg)
+ try:
+ wait_tcp_open("127.0.0.1", free_port)
+ iface.detach()
+ iface.process_outgoing(b"ignored")
+ assert iface._send_queue.empty()
+ assert iface.txb == 0
+ assert iface.online is False
+ finally:
+ if iface.online:
+ iface.detach()
+
+
+def test_process_incoming_empty_not_forwarded(free_port):
+ owner = RecordingOwner()
+ cfg = configobj_from_lines(
+ [
+ "name = srv",
+ "mode = server",
+ "listen_host = 127.0.0.1",
+ f"listen_port = {free_port}",
+ ]
+ )
+ iface = HTTPTunnelInterface(owner, cfg)
+ try:
+ wait_tcp_open("127.0.0.1", free_port)
+ prev = iface.rxb
+ iface.process_incoming(b"")
+ iface.process_incoming(b"")
+ assert iface.rxb == prev
+ assert owner.packets == []
+ finally:
+ iface.detach()
+
+
+def test_server_queued_packets_arrive_as_separate_frames(free_port):
+ owner_s, owner_c, server, client = make_pair(free_port)
+ try:
+ ping_client_to_server(owner_s, client)
+ server.process_outgoing(b"A")
+ server.process_outgoing(b"B")
+ payloads = owner_c.wait_packet_count_at_least(2, timeout=10.0)
+ assert payloads is not None
+ assert payloads == [b"A", b"B"]
+ finally:
+ client.detach()
+ server.detach()
+
+
+def test_sequential_client_out_two_server_packets(free_port):
+ owner_s, owner_c, server, client = make_pair(free_port)
+ try:
+ ping_client_to_server(owner_s, client)
+ client.process_outgoing(b"\x01first")
+ client.process_outgoing(b"\x02second")
+ payloads = owner_s.wait_packet_count_at_least(3, timeout=15.0)
+ assert payloads is not None
+ assert payloads[-2:] == [b"\x01first", b"\x02second"]
+ finally:
+ client.detach()
+ server.detach()
+
+
+def test_check_user_agent_false_accepts_foreign_ua(free_port):
+ owner = RecordingOwner()
+ cfg = configobj_from_lines(
+ [
+ "name = srv",
+ "mode = server",
+ "listen_host = 127.0.0.1",
+ f"listen_port = {free_port}",
+ "check_user_agent = false",
+ ]
+ )
+ iface = HTTPTunnelInterface(owner, cfg)
+ try:
+ wait_tcp_open("127.0.0.1", free_port)
+ r = httpx.post(
+ f"http://127.0.0.1:{free_port}/",
+ content=HDLC.frame(b"foreign"),
+ headers={"User-Agent": "ForeignAgent/9"},
+ timeout=5,
+ )
+ assert r.status_code == 200
+ assert owner.last_payload(timeout=6.0) == b"foreign"
+ finally:
+ iface.detach()
+
+
+def test_wrong_post_path_404_never_queues(free_port):
+ owner = RecordingOwner()
+ cfg = configobj_from_lines(
+ [
+ "name = srv",
+ "mode = server",
+ "listen_host = 127.0.0.1",
+ f"listen_port = {free_port}",
+ ]
+ )
+ iface = HTTPTunnelInterface(owner, cfg)
+ try:
+ wait_tcp_open("127.0.0.1", free_port)
+ r = httpx.post(
+ f"http://127.0.0.1:{free_port}/bogus",
+ content=HDLC.frame(b"x"),
+ headers={"User-Agent": HTTPTunnelInterface.TUNNEL_USER_AGENT},
+ timeout=5,
+ )
+ assert r.status_code == 404
+ time.sleep(0.25)
+ assert owner.packets == []
+ finally:
+ iface.detach()
+
+
+def test_serve_html_on_get_when_configured(tmp_path, free_port):
+ html_path = tmp_path / "stub.html"
+ html_path.write_text("<title>t</title><p>h</p>", encoding="utf-8")
+ owner = RecordingOwner()
+ cfg = configobj_from_lines(
+ [
+ "name = srv",
+ "mode = server",
+ "listen_host = 127.0.0.1",
+ f"listen_port = {free_port}",
+ "serve_html_page = true",
+ f"html_file_path = {html_path}",
+ ]
+ )
+ iface = HTTPTunnelInterface(owner, cfg)
+ try:
+ wait_tcp_open("127.0.0.1", free_port)
+ r = httpx.get(f"http://127.0.0.1:{free_port}/", timeout=5)
+ assert r.status_code == 200
+ assert b"<title>t</title>" in r.content
+ finally:
+ iface.detach()
+
+
+def test_client_requests_use_configured_user_agent(free_port):
+ custom_ua = "CustomTunnelAgent/2.0"
+ owner_s, owner_c, server, client = make_pair(
+ free_port,
+ user_agent=custom_ua,
+ )
+ # rebuild client with matching UA
+ client.detach()
+ url = f"http://127.0.0.1:{free_port}/"
+ client = HTTPTunnelInterface(
+ owner_c,
+ configobj_from_lines(
+ [
+ "name = cli",
+ "mode = client",
+ f"server_url = {url}",
+ "poll_interval = 0.05",
+ f"user_agent = {custom_ua}",
+ ]
+ ),
+ )
+ try:
+ ua = client.session.headers["User-Agent"]
+ assert ua == custom_ua
+ client.process_outgoing(b"ua-ok")
+ assert owner_s.last_payload(timeout=8.0) == b"ua-ok"
+ finally:
+ client.detach()
+ server.detach()
+
+
+def test_counters_after_tunnel_roundtrip_stay_consistent(free_port):
+ owner_s, owner_c, server, client = make_pair(free_port)
+ try:
+ p = b"dcounter-test"
+ client.process_outgoing(p)
+ assert owner_s.last_payload(timeout=8.0) == p
+ assert client.txb >= len(p)
+ assert server.rxb >= len(p)
+
+ reply = b"back-at-you"
+ time.sleep(0.05)
+ server.process_outgoing(reply)
+ assert owner_c.last_payload(timeout=8.0) == reply
+ assert server.txb >= len(reply)
+ assert client.rxb >= len(reply)
+ finally:
+ client.detach()
+ server.detach()
+
+
+def test_subclass_matches_rns_interface():
+ from RNS.Interfaces.Interface import Interface
+
+ assert issubclass(HTTPTunnelInterface, Interface)
+ assert http_mod.interface_class is HTTPTunnelInterface
+
+
+def test_payloads_with_flag_and_esc_bytes_survive_roundtrip(free_port):
+ owner_s, owner_c, server, client = make_pair(free_port)
+ try:
+ payload = bytes([HDLC.FLAG, 0x00, HDLC.ESC, 0xFF, HDLC.FLAG, HDLC.ESC])
+ client.process_outgoing(payload)
+ assert owner_s.last_payload(timeout=8.0) == payload
+ finally:
+ client.detach()
+ server.detach()
+
+
+def test_client_reuses_single_pooled_tcp_connection(free_port):
+ owner_s, owner_c, server, client = make_pair(free_port, poll_interval=0.05)
+ try:
+ expected = []
+ for i in range(8):
+ payload = f"pool-{i}".encode()
+ expected.append(payload)
+ client.process_outgoing(payload)
+
+ payloads = owner_s.wait_packet_count_at_least(8, timeout=15.0)
+ assert payloads is not None
+ assert payloads[-8:] == expected
+
+ deadline = time.monotonic() + 5.0
+ stats = {}
+ while time.monotonic() < deadline:
+ stats = server.connection_stats()
+ if stats.get("http_requests", 0) >= 8 and stats.get("tcp_accepts", 0) >= 1:
+ break
+ time.sleep(0.05)
+
+ assert stats["http_requests"] >= 8
+ # wait_tcp_open probes once, then the client should reuse a single TCP session
+ assert stats["tcp_accepts"] <= 2
+ assert stats["http_requests"] > stats["tcp_accepts"]
+
+ cstats = client.connection_stats()
+ assert cstats.get("client_requests", 0) >= 8
+ assert cstats.get("last_http_version") == "HTTP/1.1"
+ assert client.http_version == 1
+ finally:
+ client.detach()
+ server.detach()
+
+
+def test_server_advertises_http11_keepalive_headers(free_port):
+ owner_s, owner_c, server, client = make_pair(
+ free_port,
+ poll_interval=0.05,
+ keepalive_timeout=45,
+ )
+ try:
+ ping_client_to_server(owner_s, client)
+ r = httpx.post(
+ f"http://127.0.0.1:{free_port}/",
+ content=HDLC.frame(b"hdr"),
+ headers={
+ "User-Agent": HTTPTunnelInterface.TUNNEL_USER_AGENT,
+ "Connection": "keep-alive",
+ },
+ timeout=5,
+ )
+ assert r.status_code == 200
+ assert r.headers.get("Connection", "").lower() == "keep-alive"
+ assert "timeout=45" in r.headers.get("Keep-Alive", "")
+ finally:
+ client.detach()
+ server.detach()
+
+
+def test_invalid_pool_size_rejected():
+ with pytest.raises(ValueError, match="pool_connections"):
+ HTTPTunnelInterface(
+ RecordingOwner(),
+ configobj_from_lines(
+ [
+ "name = c",
+ "mode = client",
+ "server_url = http://127.0.0.1:9/",
+ "pool_maxsize = 0",
+ ]
+ ),
+ )

diff --git a/vendor/rns_over_http/tests/test_live_rns.py b/vendor/rns_over_http/tests/test_live_rns.py
new file mode 100644
index 00000000..0162ad4c
--- /dev/null
+++ b/vendor/rns_over_http/tests/test_live_rns.py
@@ -0,0 +1,266 @@
+"""Live tests with full Reticulum instances over HTTPTunnelInterface.
+
+Spawns isolated server and client shared instances in subprocesses, exchanges
+traffic over the HTTP tunnel, and verifies a local program can attach to the
+server shared instance via LocalInterface.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import socket
+import subprocess
+import sys
+import textwrap
+import time
+from pathlib import Path
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+INTERFACE_SRC = REPO_ROOT / "HTTPInterface.py"
+PEER_HELPER = Path(__file__).resolve().parent / "_live_rns_peer.py"
+
+
+def free_tcp_port():
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
+
+def write_rns_config(
+ configdir: Path,
+ *,
+ instance_name: str,
+ shared_port: int,
+ control_port: int,
+ http_mode: str,
+ listen_port: int | None = None,
+ server_url: str | None = None,
+):
+ configdir.mkdir(parents=True, exist_ok=True)
+ interfaces_dir = configdir / "interfaces"
+ interfaces_dir.mkdir(exist_ok=True)
+ shutil.copy2(INTERFACE_SRC, interfaces_dir / "HTTPInterface.py")
+
+ if http_mode == "server":
+ http_block = textwrap.dedent(
+ f"""
+ [[HTTP Tunnel]]
+ type = HTTPInterface
+ enabled = yes
+ mode = server
+ listen_host = 127.0.0.1
+ listen_port = {listen_port}
+ mtu = 4096
+ poll_interval = 0.05
+ check_user_agent = yes
+ """
+ )
+ else:
+ http_block = textwrap.dedent(
+ f"""
+ [[HTTP Tunnel]]
+ type = HTTPInterface
+ enabled = yes
+ mode = client
+ server_url = {server_url}
+ mtu = 4096
+ poll_interval = 0.05
+ """
+ )
+
+ config = textwrap.dedent(
+ f"""
+ [reticulum]
+ enable_transport = Yes
+ share_instance = Yes
+ instance_name = {instance_name}
+ shared_instance_port = {shared_port}
+ instance_control_port = {control_port}
+ shared_instance_type = tcp
+
+ [logging]
+ loglevel = 3
+
+ [interfaces]
+ [[Default Interface]]
+ type = AutoInterface
+ enabled = No
+
+ {http_block}
+ """
+ )
+ (configdir / "config").write_text(config, encoding="utf-8")
+
+
+def wait_file(path: Path, timeout=30.0):
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if path.is_file() and path.stat().st_size > 0:
+ return path.read_text(encoding="utf-8").strip()
+ time.sleep(0.1)
+ pytest.fail(f"timed out waiting for {path}")
+
+
+def start_peer(args, env=None):
+ full_env = os.environ.copy()
+ if env:
+ full_env.update(env)
+ # Ensure repo root is importable if needed
+ full_env["PYTHONPATH"] = str(REPO_ROOT) + os.pathsep + full_env.get("PYTHONPATH", "")
+ return subprocess.Popen(
+ [sys.executable, str(PEER_HELPER), *args],
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=full_env,
+ cwd=str(REPO_ROOT),
+ )
+
+
+def terminate(proc: subprocess.Popen, timeout=8.0):
+ if proc.poll() is not None:
+ return
+ proc.terminate()
+ try:
+ proc.wait(timeout=timeout)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait(timeout=5)
+
+
+@pytest.fixture
+def live_dirs(tmp_path):
+ server_dir = tmp_path / "rns_server"
+ client_dir = tmp_path / "rns_client"
+ status_dir = tmp_path / "status"
+ status_dir.mkdir()
+ return server_dir, client_dir, status_dir
+
+
+@pytest.mark.live
+def test_live_rns_http_tunnel_link_and_local_client(live_dirs):
+ server_dir, client_dir, status_dir = live_dirs
+ http_port = free_tcp_port()
+ server_shared = free_tcp_port()
+ server_control = free_tcp_port()
+ client_shared = free_tcp_port()
+ client_control = free_tcp_port()
+
+ write_rns_config(
+ server_dir,
+ instance_name="http-live-server",
+ shared_port=server_shared,
+ control_port=server_control,
+ http_mode="server",
+ listen_port=http_port,
+ )
+ write_rns_config(
+ client_dir,
+ instance_name="http-live-client",
+ shared_port=client_shared,
+ control_port=client_control,
+ http_mode="client",
+ server_url=f"http://127.0.0.1:{http_port}/",
+ )
+
+ server_announce = status_dir / "server_announce.hash"
+ server_recv = status_dir / "server_recv.json"
+ client_done = status_dir / "client_done.json"
+ local_done = status_dir / "local_done.json"
+
+ server_proc = start_peer(
+ [
+ "serve",
+ "--configdir",
+ str(server_dir),
+ "--announce-file",
+ str(server_announce),
+ "--recv-file",
+ str(server_recv),
+ ]
+ )
+ try:
+ dest_hash = wait_file(server_announce, timeout=45.0)
+
+ # Allow HTTP listener to come up
+ deadline = time.monotonic() + 15.0
+ while time.monotonic() < deadline:
+ try:
+ with socket.create_connection(("127.0.0.1", http_port), timeout=0.3):
+ break
+ except OSError:
+ time.sleep(0.1)
+ else:
+ err = server_proc.stderr.read().decode("utf-8", errors="replace") if server_proc.stderr else ""
+ pytest.fail(f"HTTP server port did not open\n{err}")
+
+ client_proc = start_peer(
+ [
+ "client",
+ "--configdir",
+ str(client_dir),
+ "--target-hash",
+ dest_hash,
+ "--done-file",
+ str(client_done),
+ "--payload",
+ "live-http-rns-ok",
+ ]
+ )
+ try:
+ done_raw = wait_file(client_done, timeout=90.0)
+ done = json.loads(done_raw)
+ assert done.get("ok") is True, done
+
+ recv_raw = wait_file(server_recv, timeout=30.0)
+ recv = json.loads(recv_raw)
+ assert recv.get("payload") == "live-http-rns-ok"
+
+ # Local program attaches to the server shared instance
+ local_proc = start_peer(
+ [
+ "local_client",
+ "--configdir",
+ str(server_dir),
+ "--done-file",
+ str(local_done),
+ "--expect-hash",
+ dest_hash,
+ ]
+ )
+ local = {}
+ try:
+ local_raw = wait_file(local_done, timeout=45.0)
+ local = json.loads(local_raw)
+ assert local.get("ok") is True, local
+ assert local.get("connected_to_shared") is True
+ finally:
+ terminate(local_proc)
+ out, err = local_proc.communicate(timeout=5)
+ if not local.get("ok"):
+ pytest.fail(
+ "local client failed\n"
+ + out.decode("utf-8", errors="replace")
+ + err.decode("utf-8", errors="replace")
+ )
+ finally:
+ terminate(client_proc)
+ cout, cerr = client_proc.communicate(timeout=5)
+ if not client_done.is_file():
+ pytest.fail(
+ "client peer failed\n"
+ + cout.decode("utf-8", errors="replace")
+ + cerr.decode("utf-8", errors="replace")
+ )
+ finally:
+ terminate(server_proc)
+ sout, serr = server_proc.communicate(timeout=5)
+ if not server_announce.is_file():
+ pytest.fail(
+ "server peer failed\n"
+ + sout.decode("utf-8", errors="replace")
+ + serr.decode("utf-8", errors="replace")
+ )

diff --git a/vendor/rns_over_http/tests/tls_certs.py b/vendor/rns_over_http/tests/tls_certs.py
new file mode 100644
index 00000000..940f4bf5
--- /dev/null
+++ b/vendor/rns_over_http/tests/tls_certs.py
@@ -0,0 +1,51 @@
+"""Helpers for generating short-lived self-signed TLS material in tests."""
+
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.x509.oid import NameOID
+
+
+def write_self_signed_cert(directory, common_name="localhost"):
+ """Write cert.pem and key.pem into directory. Return (cert_path, key_path)."""
+ directory = Path(directory)
+ directory.mkdir(parents=True, exist_ok=True)
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+ subject = issuer = x509.Name(
+ [x509.NameAttribute(NameOID.COMMON_NAME, common_name)]
+ )
+ now = datetime.now(timezone.utc)
+ cert = (
+ x509.CertificateBuilder()
+ .subject_name(subject)
+ .issuer_name(issuer)
+ .public_key(key.public_key())
+ .serial_number(x509.random_serial_number())
+ .not_valid_before(now - timedelta(minutes=1))
+ .not_valid_after(now + timedelta(days=1))
+ .add_extension(
+ x509.SubjectAlternativeName(
+ [
+ x509.DNSName("localhost"),
+ x509.IPAddress(__import__("ipaddress").IPv4Address("127.0.0.1")),
+ ]
+ ),
+ critical=False,
+ )
+ .sign(key, hashes.SHA256())
+ )
+
+ cert_path = directory / "cert.pem"
+ key_path = directory / "key.pem"
+ cert_path.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
+ key_path.write_bytes(
+ key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.TraditionalOpenSSL,
+ encryption_algorithm=serialization.NoEncryption(),
+ )
+ )
+ return cert_path, key_path


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────